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

@@ -13,16 +13,13 @@ weight: 813
------- -------
## V4.8.11 更新预告 ## V4.8.11 更新说明
1. 1.
2. 新增 - 工作流循环执行节点 2. 新增 - 聊天记录滚动加载,不再只加载 30 条
3. 新增 - 工作流用户表单输入节点 3. 新增 - 工作流增加触摸板优先模式
4. 新增 - 插件输出,支持指定某些字段为工具调用结果 4. 优化 - 工作流嵌套层级限制 20 层,避免因编排不合理导致的无限死循环
5. 新增 - 插件支持配置使用引导、全局变量和文件输入 5. 优化 - 工作流 handler 性能优化
6. 新增 - 简易模式支持新的版本管理方式 6. 优化 - 工作流快捷键,避免调试测试时也会触发
7. 新增 - 聊天记录滚动加载,不再只加载 30 条 7. 修复 - 知识库选择权限问题
8. 优化 - 工作流嵌套层级限制 20 层,避免因编排不合理导致的无限死循环 8. 修复 - 空 chatId 发起对话,首轮携带用户选择时会异常
9. 优化 - 工作流 handler 性能优化。
10. 修复 - 知识库选择权限问题。
11. 修复 - 空 chatId 发起对话,首轮携带用户选择时会异常。

View File

@@ -2,10 +2,8 @@
export const iconPaths = { export const iconPaths = {
book: () => import('./icons/book.svg'), book: () => import('./icons/book.svg'),
visible: () => import('./icons/visible.svg'),
change: () => import('./icons/change.svg'), change: () => import('./icons/change.svg'),
chatSend: () => import('./icons/chatSend.svg'), chatSend: () => import('./icons/chatSend.svg'),
configmap: () => import('./icons/configmap.svg'),
closeSolid: () => import('./icons/closeSolid.svg'), closeSolid: () => import('./icons/closeSolid.svg'),
collectionLight: () => import('./icons/collectionLight.svg'), collectionLight: () => import('./icons/collectionLight.svg'),
collectionSolid: () => import('./icons/collectionSolid.svg'), collectionSolid: () => import('./icons/collectionSolid.svg'),
@@ -80,6 +78,7 @@ export const iconPaths = {
'common/voiceLight': () => import('./icons/common/voiceLight.svg'), 'common/voiceLight': () => import('./icons/common/voiceLight.svg'),
'common/warn': () => import('./icons/common/warn.svg'), 'common/warn': () => import('./icons/common/warn.svg'),
'common/wechatFill': () => import('./icons/common/wechatFill.svg'), 'common/wechatFill': () => import('./icons/common/wechatFill.svg'),
configmap: () => import('./icons/configmap.svg'),
copy: () => import('./icons/copy.svg'), copy: () => import('./icons/copy.svg'),
'core/app/aiFill': () => import('./icons/core/app/aiFill.svg'), 'core/app/aiFill': () => import('./icons/core/app/aiFill.svg'),
'core/app/aiLight': () => import('./icons/core/app/aiLight.svg'), 'core/app/aiLight': () => import('./icons/core/app/aiLight.svg'),
@@ -199,6 +198,7 @@ export const iconPaths = {
import('./icons/core/workflow/inputType/selectLLM.svg'), import('./icons/core/workflow/inputType/selectLLM.svg'),
'core/workflow/inputType/switch': () => import('./icons/core/workflow/inputType/switch.svg'), 'core/workflow/inputType/switch': () => import('./icons/core/workflow/inputType/switch.svg'),
'core/workflow/inputType/textarea': () => import('./icons/core/workflow/inputType/textarea.svg'), 'core/workflow/inputType/textarea': () => import('./icons/core/workflow/inputType/textarea.svg'),
'core/workflow/mouse': () => import('./icons/core/workflow/mouse.svg'),
'core/workflow/publish': () => import('./icons/core/workflow/publish.svg'), 'core/workflow/publish': () => import('./icons/core/workflow/publish.svg'),
'core/workflow/redo': () => import('./icons/core/workflow/redo.svg'), 'core/workflow/redo': () => import('./icons/core/workflow/redo.svg'),
'core/workflow/revertVersion': () => import('./icons/core/workflow/revertVersion.svg'), 'core/workflow/revertVersion': () => import('./icons/core/workflow/revertVersion.svg'),
@@ -249,6 +249,7 @@ export const iconPaths = {
import('./icons/core/workflow/template/variableUpdate.svg'), import('./icons/core/workflow/template/variableUpdate.svg'),
'core/workflow/template/workflowStart': () => 'core/workflow/template/workflowStart': () =>
import('./icons/core/workflow/template/workflowStart.svg'), import('./icons/core/workflow/template/workflowStart.svg'),
'core/workflow/touchTable': () => import('./icons/core/workflow/touchTable.svg'),
'core/workflow/undo': () => import('./icons/core/workflow/undo.svg'), 'core/workflow/undo': () => import('./icons/core/workflow/undo.svg'),
'core/workflow/upload': () => import('./icons/core/workflow/upload.svg'), 'core/workflow/upload': () => import('./icons/core/workflow/upload.svg'),
'core/workflow/versionHistories': () => import('./icons/core/workflow/versionHistories.svg'), 'core/workflow/versionHistories': () => import('./icons/core/workflow/versionHistories.svg'),
@@ -332,5 +333,6 @@ export const iconPaths = {
text: () => import('./icons/text.svg'), text: () => import('./icons/text.svg'),
union: () => import('./icons/union.svg'), union: () => import('./icons/union.svg'),
user: () => import('./icons/user.svg'), user: () => import('./icons/user.svg'),
visible: () => import('./icons/visible.svg'),
wx: () => import('./icons/wx.svg') wx: () => import('./icons/wx.svg')
}; };

View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 14 16" >
<path d="M7.00001 4.02376C6.50295 4.02376 6.10001 4.4267 6.10001 4.92376V6.63711C6.10001 7.13417 6.50295 7.53711 7.00001 7.53711C7.49706 7.53711 7.90001 7.13417 7.90001 6.63711V4.92376C7.90001 4.4267 7.49706 4.02376 7.00001 4.02376Z" />
<path fill-rule="evenodd" clip-rule="evenodd" d="M0.982849 6.14213C0.982849 5.42966 0.982849 5.07342 1.01738 4.77382C1.29671 2.35052 3.20837 0.438866 5.63167 0.159535C5.93127 0.125 6.2875 0.125 6.99998 0.125C7.71246 0.125 8.0687 0.125 8.3683 0.159535C10.7916 0.438866 12.7032 2.35052 12.9826 4.77382C13.0171 5.07342 13.0171 5.42966 13.0171 6.14213V9.85787C13.0171 10.5703 13.0171 10.9266 12.9826 11.2262C12.7032 13.6495 10.7916 15.5611 8.3683 15.8405C8.0687 15.875 7.71246 15.875 6.99998 15.875C6.2875 15.875 5.93127 15.875 5.63167 15.8405C3.20837 15.5611 1.29671 13.6495 1.01738 11.2262C0.982849 10.9266 0.982849 10.5703 0.982849 9.85787V6.14213ZM11.5171 6.14213V9.85787C11.5171 10.6121 11.5146 10.862 11.4924 11.0544C11.2929 12.7853 9.92745 14.1508 8.19653 14.3503C8.00411 14.3725 7.75422 14.375 6.99998 14.375C6.24574 14.375 5.99586 14.3725 5.80343 14.3503C4.07251 14.1508 2.70704 12.7853 2.50752 11.0544C2.48534 10.862 2.48285 10.6121 2.48285 9.85787V6.14213C2.48285 5.3879 2.48534 5.13801 2.50752 4.94558C2.70704 3.21466 4.07251 1.84919 5.80343 1.64967C5.99586 1.62749 6.24574 1.625 6.99998 1.625C7.75422 1.625 8.00411 1.62749 8.19653 1.64967C9.92745 1.84919 11.2929 3.21466 11.4924 4.94558C11.5146 5.13801 11.5171 5.3879 11.5171 6.14213Z" />
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 18 18" >
<path fill-rule="evenodd" clip-rule="evenodd" d="M15 15.75C16.2426 15.75 17.25 14.7426 17.25 13.5V4.5C17.25 3.25736 16.2426 2.25 15 2.25H3C1.75736 2.25 0.75 3.25736 0.75 4.5V13.5C0.75 14.7426 1.75736 15.75 3 15.75H15ZM3 3.75H15C15.4142 3.75 15.75 4.08579 15.75 4.5V10.4111H2.25V4.5C2.25 4.08579 2.58579 3.75 3 3.75ZM2.25 11.9111V13.5C2.25 13.9142 2.58579 14.25 3 14.25H8.25002V11.9111H2.25ZM9.75002 14.25V11.9111H15.75V13.5C15.75 13.9142 15.4142 14.25 15 14.25H9.75002Z" />
</svg>

After

Width:  |  Height:  |  Size: 545 B

View File

@@ -35,6 +35,7 @@ const MultipleRowSelect = ({
return ( return (
<> <>
<Box <Box
className="nowheel"
flex={'1 0 auto'} flex={'1 0 auto'}
// width={0} // width={0}
px={2} px={2}

View File

@@ -100,6 +100,7 @@ export default function Editor({
return ( return (
<Box <Box
className="nowheel"
position={'relative'} position={'relative'}
width={'full'} width={'full'}
h={`${height}px`} h={`${height}px`}

View File

@@ -90,12 +90,14 @@
"less_than_or_equal_to": "Less Than or Equal To", "less_than_or_equal_to": "Less Than or Equal To",
"max_dialog_rounds": "Maximum Number of Dialog Rounds", "max_dialog_rounds": "Maximum Number of Dialog Rounds",
"max_tokens": "Maximum Tokens", "max_tokens": "Maximum Tokens",
"mouse_priority": "Mouse first",
"new_context": "New Context", "new_context": "New Context",
"not_contains": "Does Not Contain", "not_contains": "Does Not Contain",
"only_the_reference_type_is_supported": "Only reference type is supported", "only_the_reference_type_is_supported": "Only reference type is supported",
"optional_value_type": "Optional Value Type", "optional_value_type": "Optional Value Type",
"optional_value_type_tip": "You can specify one or more data types. When dynamically adding fields, users can only select the configured types.", "optional_value_type_tip": "You can specify one or more data types. When dynamically adding fields, users can only select the configured types.",
"other_questions": "Other Questions", "other_questions": "Other Questions",
"pan_priority": "Touchpad first",
"pass_returned_object_as_output_to_next_nodes": "Pass the object returned in the code as output to the next nodes. The variable name needs to correspond to the return key.", "pass_returned_object_as_output_to_next_nodes": "Pass the object returned in the code as output to the next nodes. The variable name needs to correspond to the return key.",
"plugin": { "plugin": {
"Instruction_Tip": "You can configure an instruction to explain the purpose of the plugin. This instruction will be displayed each time the plugin is used. Supports standard Markdown syntax.", "Instruction_Tip": "You can configure an instruction to explain the purpose of the plugin. This instruction will be displayed each time the plugin is used. Supports standard Markdown syntax.",

View File

@@ -90,12 +90,14 @@
"less_than_or_equal_to": "小于等于", "less_than_or_equal_to": "小于等于",
"max_dialog_rounds": "最多携带多少轮对话记录", "max_dialog_rounds": "最多携带多少轮对话记录",
"max_tokens": "最大 Tokens", "max_tokens": "最大 Tokens",
"mouse_priority": "鼠标优先",
"new_context": "新的上下文", "new_context": "新的上下文",
"not_contains": "不包含", "not_contains": "不包含",
"only_the_reference_type_is_supported": "仅支持引用类型", "only_the_reference_type_is_supported": "仅支持引用类型",
"optional_value_type": "可选的数据类型", "optional_value_type": "可选的数据类型",
"optional_value_type_tip": "可以指定 1 个或多个数据类型,用户在动态添加字段时,仅可选择配置的类型", "optional_value_type_tip": "可以指定 1 个或多个数据类型,用户在动态添加字段时,仅可选择配置的类型",
"other_questions": "其他问题", "other_questions": "其他问题",
"pan_priority": "触摸板优先",
"pass_returned_object_as_output_to_next_nodes": "将代码中 return 的对象作为输出,传递给后续的节点。变量名需要对应 return 的 key", "pass_returned_object_as_output_to_next_nodes": "将代码中 return 的对象作为输出,传递给后续的节点。变量名需要对应 return 的 key",
"plugin": { "plugin": {
"Instruction_Tip": "可以配置一段说明,以解释该插件的用途。每次使用插件前,会显示该段说明。支持标准 Markdown 语法。", "Instruction_Tip": "可以配置一段说明,以解释该插件的用途。每次使用插件前,会显示该段说明。支持标准 Markdown 语法。",

View File

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

View File

@@ -33,10 +33,7 @@ const AppCard = ({
const { appDetail, onOpenInfoEdit, onOpenTeamTagModal, onDelApp, currentTab } = const { appDetail, onOpenInfoEdit, onOpenTeamTagModal, onDelApp, currentTab } =
useContextSelector(AppContext, (v) => v); useContextSelector(AppContext, (v) => v);
const { historiesDefaultData, onSaveWorkflow, isSaving } = useContextSelector( const { historiesDefaultData } = useContextSelector(WorkflowContext, (v) => v);
WorkflowContext,
(v) => v
);
const { isOpen: isOpenImport, onOpen: onOpenImport, onClose: onCloseImport } = useDisclosure(); 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 { Background, ControlButton, MiniMap, Panel, useReactFlow, useViewport } from 'reactflow';
import { useContextSelector } from 'use-context-selector'; import { useContextSelector } from 'use-context-selector';
import { WorkflowContext } from '../../context'; import { WorkflowContext } from '../../context';
import MyTooltip from '@fastgpt/web/components/common/MyTooltip'; import MyTooltip from '@fastgpt/web/components/common/MyTooltip';
import MyIcon from '@fastgpt/web/components/common/Icon'; import MyIcon from '@fastgpt/web/components/common/Icon';
import { Box } from '@chakra-ui/react'; import { Box } from '@chakra-ui/react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'next-i18next';
import styles from './index.module.scss'; import styles from './index.module.scss';
import { maxZoom, minZoom } from '../index'; import { maxZoom, minZoom } from '../index';
import { useKeyPress } from 'ahooks';
const buttonStyle = {
border: 'none',
borderRadius: '6px',
padding: '7px'
};
const FlowController = React.memo(function FlowController() { const FlowController = React.memo(function FlowController() {
const { fitView, zoomIn, zoomOut } = useReactFlow(); const { fitView, zoomIn, zoomOut } = useReactFlow();
const { zoom } = useViewport(); 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 { t } = useTranslation();
const isMac = !window ? false : window.navigator.userAgent.toLocaleLowerCase().includes('mac'); const isMac = !window ? false : window.navigator.userAgent.toLocaleLowerCase().includes('mac');
useEffect(() => { // Controller shortcut key
const keyDownHandler = (event: KeyboardEvent) => { useKeyPress(['ctrl.z', 'meta.z'], (e) => {
if ( e.preventDefault();
(event.key === 'z' || event.key === 'Z') && if (!mouseInCanvas) return;
(event.ctrlKey || event.metaKey) && undo();
event.shiftKey });
) { useKeyPress(['ctrl.shift.z', 'meta.shift.z', 'ctrl.y', 'meta.y'], (e) => {
event.preventDefault(); e.preventDefault();
redo(); if (!mouseInCanvas) return;
} else if (event.key === 'z' && (event.ctrlKey || event.metaKey)) { redo();
event.preventDefault(); });
undo(); useKeyPress(['ctrl.add', 'meta.add', 'ctrl.equalsign', 'meta.equalsign'], (e) => {
} else if ((event.key === '=' || event.key === '+') && (event.ctrlKey || event.metaKey)) { e.preventDefault();
event.preventDefault(); if (!mouseInCanvas) return;
zoomIn(); zoomIn();
} else if (event.key === '-' && (event.ctrlKey || event.metaKey)) { });
event.preventDefault(); useKeyPress(['ctrl.dash', 'meta.dash'], (e) => {
zoomOut(); e.preventDefault();
} if (!mouseInCanvas) return;
}; zoomOut();
});
document.addEventListener('keydown', keyDownHandler);
return () => {
document.removeEventListener('keydown', keyDownHandler);
};
}, [undo, redo, zoomIn, zoomOut]);
const buttonStyle = {
border: 'none',
borderRadius: '6px',
padding: '7px'
};
const Render = useMemo(() => { const Render = useMemo(() => {
return ( 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)' '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 */} {/* undo */}
<MyTooltip label={isMac ? t('common:common.undo_tip_mac') : t('common:common.undo_tip')}> <MyTooltip label={isMac ? t('common:common.undo_tip_mac') : t('common:common.undo_tip')}>
<ControlButton <ControlButton
@@ -107,7 +139,7 @@ const FlowController = React.memo(function FlowController() {
{/* zoom out */} {/* zoom out */}
<MyTooltip <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 <ControlButton
onClick={() => zoomOut()} onClick={() => zoomOut()}
@@ -121,7 +153,7 @@ const FlowController = React.memo(function FlowController() {
{/* zoom in */} {/* zoom in */}
<MyTooltip <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 <ControlButton
onClick={() => zoomIn()} onClick={() => zoomIn()}
@@ -149,7 +181,20 @@ const FlowController = React.memo(function FlowController() {
<Background /> <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; 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 MyModal from '@fastgpt/web/components/common/MyModal';
import { useToast } from '@fastgpt/web/hooks/useToast'; import { useToast } from '@fastgpt/web/hooks/useToast';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'next-i18next';
type FormType = { type FormType = {
versionName: string; versionName: string;
isPublish: boolean | undefined; 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 { getNanoid } from '@fastgpt/global/common/string/tools';
import { useCopyData } from '@/web/common/hooks/useCopyData'; import { useCopyData } from '@/web/common/hooks/useCopyData';
import { useTranslation } from 'next-i18next'; import { useTranslation } from 'next-i18next';
import { Node } from 'reactflow'; import { Node, useKeyPress } from 'reactflow';
import { FlowNodeItemType } from '@fastgpt/global/core/workflow/type/node'; import { FlowNodeItemType } from '@fastgpt/global/core/workflow/type/node';
import { useContextSelector } from 'use-context-selector'; import { useContextSelector } from 'use-context-selector';
import { WorkflowContext, getWorkflowStore } from '../../context'; import { WorkflowContext, getWorkflowStore } from '../../context';
import { useWorkflowUtils } from './useUtils'; import { useWorkflowUtils } from './useUtils';
import { useKeyPress as useKeyPressEffect } from 'ahooks';
export const useKeyboard = () => { export const useKeyboard = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const { setNodes, onSaveWorkflow } = useContextSelector(WorkflowContext, (v) => v); const { setNodes, mouseInCanvas } = useContextSelector(WorkflowContext, (v) => v);
const { copyData } = useCopyData(); const { copyData } = useCopyData();
const { computedNewNodeName } = useWorkflowUtils(); const { computedNewNodeName } = useWorkflowUtils();
const [isDowningCtrl, setIsDowningCtrl] = useState(false); const isDowningCtrl = useKeyPress(['Meta', 'Control']);
const hasInputtingElement = useCallback(() => { const hasInputtingElement = useCallback(() => {
const activeElement = document.activeElement; const activeElement = document.activeElement;
@@ -84,48 +85,20 @@ export const useKeyboard = () => {
} catch (error) {} } catch (error) {}
}, [computedNewNodeName, hasInputtingElement, setNodes]); }, [computedNewNodeName, hasInputtingElement, setNodes]);
const handleKeyDown = useCallback( useKeyPressEffect(['ctrl.c', 'meta.c'], (e) => {
(event: KeyboardEvent) => { e.preventDefault();
if (event.ctrlKey || event.metaKey) { if (!mouseInCanvas) return;
setIsDowningCtrl(true); onCopy();
});
switch (event.key) { useKeyPressEffect(['ctrl.v', 'meta.v'], (e) => {
case 'c': e.preventDefault();
onCopy(); if (!mouseInCanvas) return;
break; onParse();
case 'v': });
onParse(); useKeyPressEffect(['ctrl.s', 'meta.s'], (e) => {
break; e.preventDefault();
case 's': if (!mouseInCanvas) return;
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]);
return { return {
isDowningCtrl isDowningCtrl

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -6,7 +6,7 @@ import {
storeNode2FlowNode storeNode2FlowNode
} from '@/web/core/workflow/utils'; } from '@/web/core/workflow/utils';
import { getErrText } from '@fastgpt/global/common/error/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 { FlowNodeTypeEnum } from '@fastgpt/global/core/workflow/node/constant';
import { RuntimeNodeItemType } from '@fastgpt/global/core/workflow/runtime/type'; import { RuntimeNodeItemType } from '@fastgpt/global/core/workflow/runtime/type';
import { FlowNodeItemType, StoreNodeItemType } from '@fastgpt/global/core/workflow/type/node'; 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 { useRequest2 } from '@fastgpt/web/hooks/useRequest';
import { formatTime2YMDHMS, formatTime2YMDHMW } from '@fastgpt/global/common/string/time'; import { formatTime2YMDHMS, formatTime2YMDHMW } from '@fastgpt/global/common/string/time';
import type { InitProps } from '@/pages/app/detail/components/PublishHistoriesSlider'; 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; type OnChange<ChangesType> = (changes: ChangesType[]) => void;
@@ -64,6 +65,7 @@ type WorkflowContextType = {
basicNodeTemplates: FlowNodeTemplateType[]; basicNodeTemplates: FlowNodeTemplateType[];
filterAppIds?: string[]; filterAppIds?: string[];
reactFlowWrapper: React.RefObject<HTMLDivElement> | null; reactFlowWrapper: React.RefObject<HTMLDivElement> | null;
mouseInCanvas: boolean;
// nodes // nodes
nodes: Node<FlowNodeItemType, string | undefined>[]; nodes: Node<FlowNodeItemType, string | undefined>[];
@@ -145,8 +147,6 @@ type WorkflowContextType = {
edges: StoreEdgeItemType[]; edges: StoreEdgeItemType[];
} }
| undefined; | undefined;
onSaveWorkflow: () => Promise<null | undefined>;
isSaving: boolean;
// debug // debug
workflowDebugData: workflowDebugData:
@@ -182,6 +182,10 @@ type WorkflowContextType = {
| undefined | undefined
> >
>; >;
//
workflowControlMode?: 'drag' | 'select';
setWorkflowControlMode: (value?: SetState<'drag' | 'select'> | undefined) => void;
}; };
type DebugDataType = { type DebugDataType = {
@@ -192,7 +196,6 @@ type DebugDataType = {
}; };
export const WorkflowContext = createContext<WorkflowContextType>({ export const WorkflowContext = createContext<WorkflowContextType>({
isSaving: false,
setConnectingEdge: function ( setConnectingEdge: function (
value: React.SetStateAction<OnConnectStartParams | undefined> value: React.SetStateAction<OnConnectStartParams | undefined>
): void { ): void {
@@ -205,6 +208,7 @@ export const WorkflowContext = createContext<WorkflowContextType>({
reactFlowWrapper: null, reactFlowWrapper: null,
nodes: [], nodes: [],
nodeList: [], nodeList: [],
mouseInCanvas: false,
setNodes: function ( setNodes: function (
value: React.SetStateAction<Node<FlowNodeItemType, string | undefined>[]> value: React.SetStateAction<Node<FlowNodeItemType, string | undefined>[]>
): void { ): void {
@@ -295,9 +299,6 @@ export const WorkflowContext = createContext<WorkflowContextType>({
| undefined { | undefined {
throw new Error('Function not implemented.'); throw new Error('Function not implemented.');
}, },
onSaveWorkflow: function (): Promise<null | undefined> {
throw new Error('Function not implemented.');
},
historiesDefaultData: undefined, historiesDefaultData: undefined,
setHistoriesDefaultData: function (value: React.SetStateAction<InitProps | undefined>): void { setHistoriesDefaultData: function (value: React.SetStateAction<InitProps | undefined>): void {
throw new Error('Function not implemented.'); throw new Error('Function not implemented.');
@@ -323,7 +324,11 @@ export const WorkflowContext = createContext<WorkflowContextType>({
throw new Error('Function not implemented.'); throw new Error('Function not implemented.');
}, },
canRedo: false, canRedo: false,
canUndo: false canUndo: false,
workflowControlMode: 'drag',
setWorkflowControlMode: function (value?: SetState<'drag' | 'select'> | undefined): void {
throw new Error('Function not implemented.');
}
}); });
const WorkflowContextProvider = ({ const WorkflowContextProvider = ({
@@ -337,9 +342,34 @@ const WorkflowContextProvider = ({
const { toast } = useToast(); const { toast } = useToast();
const reactFlowWrapper = useRef<HTMLDivElement>(null); 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 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 */ /* edge */
const [edges, setEdges, onEdgesChange] = useEdgesState([]); const [edges, setEdges, onEdgesChange] = useEdgesState([]);
const [hoverEdgeId, setHoverEdgeId] = useState<string>(); const [hoverEdgeId, setHoverEdgeId] = useState<string>();
@@ -586,33 +616,6 @@ const WorkflowContextProvider = ({
return storeNodes; 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 */ /* debug */
const [workflowDebugData, setWorkflowDebugData] = useState<DebugDataType>(); const [workflowDebugData, setWorkflowDebugData] = useState<DebugDataType>();
const onNextNodeDebug = useCallback( const onNextNodeDebug = useCallback(
@@ -944,6 +947,10 @@ const WorkflowContextProvider = ({
appId, appId,
reactFlowWrapper, reactFlowWrapper,
basicNodeTemplates, basicNodeTemplates,
workflowControlMode,
setWorkflowControlMode,
mouseInCanvas,
// node // node
nodes, nodes,
setNodes, setNodes,
@@ -984,8 +991,6 @@ const WorkflowContextProvider = ({
initData, initData,
flowData2StoreDataAndCheck, flowData2StoreDataAndCheck,
flowData2StoreData, flowData2StoreData,
onSaveWorkflow,
isSaving,
// debug // debug
workflowDebugData, workflowDebugData,

View File

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