feat: The workflow quickly adds applications (#4882)

* feat: add node by handle (#4860)

* feat: add node by handle

* fix

* fix edge filter

* fix

* move utils

* move context

* scale handle

* move postion to handle params & optimize handle scale (#4878)

* move position to handle params

* close button scale

* perf: node template ui

* remove handle scale (#4880)

* feat: handle connect

* add mouse down duration check (#4881)

* perf: long press time

* tool handle size

* optimize add node by handle (#4883)

---------

Co-authored-by: heheer <heheer@sealos.io>
This commit is contained in:
Archer
2025-05-23 19:20:12 +08:00
committed by GitHub
parent fae76e887a
commit 9709ae7a4f
28 changed files with 1307 additions and 1105 deletions

View File

@@ -11,8 +11,9 @@ weight: 790
## 🚀 新增内容 ## 🚀 新增内容
1. 支持 PG 设置`systemEnv.hnswMaxScanTuples`参数,提高迭代搜索的数据总量。 1. 支持 PG 设置`systemEnv.hnswMaxScanTuples`参数,提高迭代搜索的数据总量。
2. 开放飞书和语雀知识库到开源版 2. 工作流调整为单向接入和接出,支持快速的添加下一步节点
3. gemini 和 claude 最新模型预设 3. 开放飞书和语雀知识库到开源版
4. gemini 和 claude 最新模型预设。
## ⚙️ 优化 ## ⚙️ 优化

View File

@@ -1,5 +1,5 @@
import React, { forwardRef } from 'react'; import React, { forwardRef } from 'react';
import { Flex, Box, type BoxProps } from '@chakra-ui/react'; import { Flex, Box, type BoxProps, HStack } from '@chakra-ui/react';
import MyIcon from '../Icon'; import MyIcon from '../Icon';
type Props<T = string> = Omit<BoxProps, 'onChange'> & { type Props<T = string> = Omit<BoxProps, 'onChange'> & {
@@ -10,9 +10,22 @@ type Props<T = string> = Omit<BoxProps, 'onChange'> & {
}[]; }[];
value: T; value: T;
onChange: (e: T) => void; onChange: (e: T) => void;
iconSize?: string;
labelSize?: string;
iconGap?: number;
}; };
const FillRowTabs = ({ list, value, onChange, py = '7px', px = '12px', ...props }: Props) => { const FillRowTabs = ({
list,
value,
onChange,
py = '2.5',
px = '4',
iconSize = '18px',
labelSize = 'sm',
iconGap = 2,
...props
}: Props) => {
return ( return (
<Box <Box
display={'inline-flex'} display={'inline-flex'}
@@ -28,7 +41,7 @@ const FillRowTabs = ({ list, value, onChange, py = '7px', px = '12px', ...props
{...props} {...props}
> >
{list.map((item) => ( {list.map((item) => (
<Flex <HStack
key={item.value} key={item.value}
flex={'1 0 0'} flex={'1 0 0'}
alignItems={'center'} alignItems={'center'}
@@ -39,6 +52,7 @@ const FillRowTabs = ({ list, value, onChange, py = '7px', px = '12px', ...props
py={py} py={py}
userSelect={'none'} userSelect={'none'}
whiteSpace={'noWrap'} whiteSpace={'noWrap'}
gap={iconGap}
{...(value === item.value {...(value === item.value
? { ? {
bg: 'white', bg: 'white',
@@ -53,9 +67,9 @@ const FillRowTabs = ({ list, value, onChange, py = '7px', px = '12px', ...props
onClick: () => onChange(item.value) onClick: () => onChange(item.value)
})} })}
> >
{item.icon && <MyIcon name={item.icon as any} mr={1.5} w={'18px'} />} {item.icon && <MyIcon name={item.icon as any} w={iconSize} />}
<Box>{item.label}</Box> <Box fontSize={labelSize}>{item.label}</Box>
</Flex> </HStack>
))} ))}
</Box> </Box>
); );

View File

@@ -1,8 +1,10 @@
{ {
"Array_element": "Array element", "Array_element": "Array element",
"Array_element_index": "Index", "Array_element_index": "Index",
"Click": "Click ",
"Code": "Code", "Code": "Code",
"Confirm_sync_node": "It will be updated to the latest node configuration and fields that do not exist in the template will be deleted (including all custom fields).\n\nIf the fields are complex, it is recommended that you copy a node first and then update the original node to facilitate parameter copying.", "Confirm_sync_node": "It will be updated to the latest node configuration and fields that do not exist in the template will be deleted (including all custom fields).\n\nIf the fields are complex, it is recommended that you copy a node first and then update the original node to facilitate parameter copying.",
"Drag": "Drag ",
"Node.Open_Node_Course": "Open node course", "Node.Open_Node_Course": "Open node course",
"Node_variables": "Node variables", "Node_variables": "Node variables",
"Quote_prompt_setting": "Quote prompt", "Quote_prompt_setting": "Quote prompt",
@@ -176,6 +178,8 @@
"text_to_extract": "Text to Extract", "text_to_extract": "Text to Extract",
"these_variables_will_be_input_parameters_for_code_execution": "These variables will be input parameters for code execution", "these_variables_will_be_input_parameters_for_code_execution": "These variables will be input parameters for code execution",
"tool.tool_result": "Tool operation results", "tool.tool_result": "Tool operation results",
"to_add_node": "to add",
"to_connect_node": "to connect",
"tool_call_termination": "Stop ToolCall", "tool_call_termination": "Stop ToolCall",
"tool_custom_field": "Custom Tool", "tool_custom_field": "Custom Tool",
"tool_field": " Tool Field Parameter Configuration", "tool_field": " Tool Field Parameter Configuration",

View File

@@ -1,8 +1,10 @@
{ {
"Array_element": "数组元素", "Array_element": "数组元素",
"Array_element_index": "下标", "Array_element_index": "下标",
"Click": "点击",
"Code": "代码", "Code": "代码",
"Confirm_sync_node": "将会更新至最新的节点配置,不存在模板中的字段将会被删除(包括所有自定义字段)。\n如果字段较为复杂建议您先复制一份节点再更新原来的节点便于参数复制。", "Confirm_sync_node": "将会更新至最新的节点配置,不存在模板中的字段将会被删除(包括所有自定义字段)。\n如果字段较为复杂建议您先复制一份节点再更新原来的节点便于参数复制。",
"Drag": "拖拽",
"Node.Open_Node_Course": "查看节点教程", "Node.Open_Node_Course": "查看节点教程",
"Node_variables": "节点变量", "Node_variables": "节点变量",
"Quote_prompt_setting": "引用提示词配置", "Quote_prompt_setting": "引用提示词配置",
@@ -175,6 +177,8 @@
"text_content_extraction": "文本内容提取", "text_content_extraction": "文本内容提取",
"text_to_extract": "需要提取的文本", "text_to_extract": "需要提取的文本",
"these_variables_will_be_input_parameters_for_code_execution": "这些变量会作为代码的运行的输入参数", "these_variables_will_be_input_parameters_for_code_execution": "这些变量会作为代码的运行的输入参数",
"to_add_node": "添加节点",
"to_connect_node": "连接节点",
"tool.tool_result": "工具运行结果", "tool.tool_result": "工具运行结果",
"tool_call_termination": "工具调用终止", "tool_call_termination": "工具调用终止",
"tool_custom_field": "自定义工具变量", "tool_custom_field": "自定义工具变量",

View File

@@ -1,8 +1,10 @@
{ {
"Array_element": "陣列元素", "Array_element": "陣列元素",
"Array_element_index": "索引", "Array_element_index": "索引",
"Click": "點擊",
"Code": "程式碼", "Code": "程式碼",
"Confirm_sync_node": "將會更新至最新的節點設定,不存在於範本中的欄位將會被刪除(包含所有自訂欄位)。\n如果欄位比較複雜建議您先複製一份節點再更新原來的節點以便複製參數。", "Confirm_sync_node": "將會更新至最新的節點設定,不存在於範本中的欄位將會被刪除(包含所有自訂欄位)。\n如果欄位比較複雜建議您先複製一份節點再更新原來的節點以便複製參數。",
"Drag": "拖拽",
"Node.Open_Node_Course": "開啟節點教學課程", "Node.Open_Node_Course": "開啟節點教學課程",
"Node_variables": "節點變數", "Node_variables": "節點變數",
"Quote_prompt_setting": "引用提示詞設定", "Quote_prompt_setting": "引用提示詞設定",
@@ -176,6 +178,8 @@
"text_to_extract": "要擷取的文字", "text_to_extract": "要擷取的文字",
"these_variables_will_be_input_parameters_for_code_execution": "這些變數會作為程式碼執行的輸入參數", "these_variables_will_be_input_parameters_for_code_execution": "這些變數會作為程式碼執行的輸入參數",
"tool.tool_result": "工具運行結果", "tool.tool_result": "工具運行結果",
"to_add_node": "添加節點",
"to_connect_node": "連接節點",
"tool_call_termination": "工具呼叫終止", "tool_call_termination": "工具呼叫終止",
"tool_custom_field": "自訂工具變數", "tool_custom_field": "自訂工具變數",
"tool_field": "工具參數設定", "tool_field": "工具參數設定",

View File

@@ -32,7 +32,7 @@ import { isProduction } from '@fastgpt/global/common/system/constants';
import { useToast } from '@fastgpt/web/hooks/useToast'; import { useToast } from '@fastgpt/web/hooks/useToast';
import { import {
checkWorkflowNodeAndConnection, checkWorkflowNodeAndConnection,
storeEdgesRenderEdge, storeEdge2RenderEdge,
storeNode2FlowNode storeNode2FlowNode
} from '@/web/core/workflow/utils'; } from '@/web/core/workflow/utils';
@@ -246,7 +246,7 @@ const Header = ({
const { nodes: storeNodes, edges: storeEdges } = form2AppWorkflow(appForm, t); const { nodes: storeNodes, edges: storeEdges } = form2AppWorkflow(appForm, t);
const nodes = storeNodes.map((item) => storeNode2FlowNode({ item, t })); const nodes = storeNodes.map((item) => storeNode2FlowNode({ item, t }));
const edges = storeEdges.map((item) => storeEdgesRenderEdge({ edge: item })); const edges = storeEdges.map((item) => storeEdge2RenderEdge({ edge: item }));
const checkResults = checkWorkflowNodeAndConnection({ nodes, edges }); const checkResults = checkWorkflowNodeAndConnection({ nodes, edges });

View File

@@ -1,212 +1,46 @@
import React, { useCallback, useMemo, useState } from 'react'; import React from 'react';
import { import { Box } from '@chakra-ui/react';
Accordion, import type { FlowNodeItemType } from '@fastgpt/global/core/workflow/type/node.d';
AccordionButton, import { type Node } from 'reactflow';
AccordionIcon,
AccordionItem,
AccordionPanel,
Box,
Flex,
Grid,
HStack,
IconButton,
Input,
InputGroup,
InputLeftElement,
css
} from '@chakra-ui/react';
import type {
NodeTemplateListItemType,
NodeTemplateListType
} from '@fastgpt/global/core/workflow/type/node.d';
import { useReactFlow, type XYPosition } from 'reactflow';
import { useSystemStore } from '@/web/common/system/useSystemStore';
import { nodeTemplate2FlowNode } from '@/web/core/workflow/utils';
import { useTranslation } from 'next-i18next';
import EmptyTip from '@fastgpt/web/components/common/EmptyTip';
import {
AppNodeFlowNodeTypeMap,
FlowNodeTypeEnum
} from '@fastgpt/global/core/workflow/node/constant';
import {
getPreviewPluginNode,
getSystemPlugTemplates,
getPluginGroups,
getSystemPluginPaths
} from '@/web/core/app/api/plugin';
import { useToast } from '@fastgpt/web/hooks/useToast';
import { getErrText } from '@fastgpt/global/common/error/utils';
import { workflowNodeTemplateList } from '@fastgpt/web/core/workflow/constants';
import FillRowTabs from '@fastgpt/web/components/common/Tabs/FillRowTabs';
import { useRequest2 } from '@fastgpt/web/hooks/useRequest';
import MyIcon from '@fastgpt/web/components/common/Icon';
import { useRouter } from 'next/router';
import MyTooltip from '@fastgpt/web/components/common/MyTooltip';
import { useContextSelector } from 'use-context-selector'; import { useContextSelector } from 'use-context-selector';
import { WorkflowContext } from '../context';
import { getTeamPlugTemplates } from '@/web/core/app/api/plugin';
import { type ParentIdType } from '@fastgpt/global/common/parentFolder/type';
import MyBox from '@fastgpt/web/components/common/MyBox'; import MyBox from '@fastgpt/web/components/common/MyBox';
import FolderPath from '@/components/common/folder/Path';
import { getAppFolderPath } from '@/web/core/app/api/app';
import { useWorkflowUtils } from './hooks/useUtils';
import { moduleTemplatesFlat } from '@fastgpt/global/core/workflow/template/constants';
import { cloneDeep } from 'lodash';
import { useSystem } from '@fastgpt/web/hooks/useSystem';
import { LoopStartNode } from '@fastgpt/global/core/workflow/template/system/loop/loopStart';
import { LoopEndNode } from '@fastgpt/global/core/workflow/template/system/loop/loopEnd';
import { NodeInputKeyEnum, NodeOutputKeyEnum } from '@fastgpt/global/core/workflow/constants';
import { WorkflowNodeEdgeContext } from '../context/workflowInitContext'; import { WorkflowNodeEdgeContext } from '../context/workflowInitContext';
import CostTooltip from '@/components/core/app/plugin/CostTooltip';
import MyAvatar from '@fastgpt/web/components/common/Avatar';
import { useMemoizedFn } from 'ahooks'; import { useMemoizedFn } from 'ahooks';
import NodeTemplateListHeader from './components/NodeTemplates/header';
import NodeTemplateList from './components/NodeTemplates/list';
import { useNodeTemplates } from './components/NodeTemplates/useNodeTemplates';
type ModuleTemplateListProps = { type ModuleTemplateListProps = {
isOpen: boolean; isOpen: boolean;
onClose: () => void; onClose: () => void;
}; };
type RenderHeaderProps = {
templateType: TemplateTypeEnum;
onClose: () => void;
parentId: ParentIdType;
searchKey: string;
loadNodeTemplates: (params: any) => void;
setSearchKey: (searchKey: string) => void;
onUpdateParentId: (parentId: ParentIdType) => void;
};
type RenderListProps = {
templateType: TemplateTypeEnum;
templates: NodeTemplateListItemType[];
type: TemplateTypeEnum;
onClose: () => void;
parentId: ParentIdType;
setParentId: (parenId: ParentIdType) => any;
};
enum TemplateTypeEnum { export const sliderWidth = 460;
'basic' = 'basic',
'systemPlugin' = 'systemPlugin',
'teamPlugin' = 'teamPlugin'
}
const sliderWidth = 460;
const NodeTemplatesModal = ({ isOpen, onClose }: ModuleTemplateListProps) => { const NodeTemplatesModal = ({ isOpen, onClose }: ModuleTemplateListProps) => {
const [parentId, setParentId] = useState<ParentIdType>(''); const setNodes = useContextSelector(WorkflowNodeEdgeContext, (v) => v.setNodes);
const [searchKey, setSearchKey] = useState('');
const { feConfigs } = useSystemStore();
const basicNodeTemplates = useContextSelector(WorkflowContext, (v) => v.basicNodeTemplates);
const hasToolNode = useContextSelector(WorkflowContext, (v) => v.hasToolNode);
const nodeList = useContextSelector(WorkflowContext, (v) => v.nodeList);
const appId = useContextSelector(WorkflowContext, (v) => v.appId);
const [templateType, setTemplateType] = useState(TemplateTypeEnum.basic);
const { data: basicNodes } = useRequest2(
async () => {
if (templateType === TemplateTypeEnum.basic) {
return basicNodeTemplates
.filter((item) => {
// unique node filter
if (item.unique) {
const nodeExist = nodeList.some((node) => node.flowNodeType === item.flowNodeType);
if (nodeExist) {
return false;
}
}
// special node filter
if (item.flowNodeType === FlowNodeTypeEnum.lafModule && !feConfigs.lafEnv) {
return false;
}
// tool stop or tool params
if (
!hasToolNode &&
(item.flowNodeType === FlowNodeTypeEnum.stopTool ||
item.flowNodeType === FlowNodeTypeEnum.toolParams)
) {
return false;
}
return true;
})
.map<NodeTemplateListItemType>((item) => ({
id: item.id,
flowNodeType: item.flowNodeType,
templateType: item.templateType,
avatar: item.avatar,
name: item.name,
intro: item.intro
}));
}
},
{
manual: false,
throttleWait: 100,
refreshDeps: [basicNodeTemplates, nodeList, hasToolNode, templateType]
}
);
const { const {
data: teamAndSystemApps, templateType,
loading: isLoading, parentId,
runAsync: loadNodeTemplates templatesIsLoading,
} = useRequest2( templates,
async ({ loadNodeTemplates,
parentId = '', onUpdateParentId
type = templateType, } = useNodeTemplates();
searchVal = searchKey
}: {
parentId?: ParentIdType;
type?: TemplateTypeEnum;
searchVal?: string;
}) => {
if (type === TemplateTypeEnum.teamPlugin) {
return getTeamPlugTemplates({
parentId,
searchKey: searchVal
}).then((res) => res.filter((app) => app.id !== appId));
}
if (type === TemplateTypeEnum.systemPlugin) {
return getSystemPlugTemplates({
searchKey: searchVal,
parentId
});
}
},
{
onSuccess(res, [{ parentId = '', type = templateType }]) {
setParentId(parentId);
setTemplateType(type);
},
refreshDeps: [searchKey, templateType]
}
);
const templates = useMemo( const onAddNode = useMemoizedFn(async ({ newNodes }: { newNodes: Node<FlowNodeItemType>[] }) => {
() => basicNodes || teamAndSystemApps || [], setNodes((state) => {
[basicNodes, teamAndSystemApps] const newState = state
); .map((node) => ({
...node,
const onUpdateParentId = useCallback( selected: false
(parentId: ParentIdType) => { }))
loadNodeTemplates({ // @ts-ignore
parentId .concat(newNodes);
}); return newState;
}, });
[loadNodeTemplates] });
);
// Init load refresh templates
useRequest2(
() =>
loadNodeTemplates({
parentId: '',
searchVal: searchKey
}),
{
manual: false,
throttleWait: 300,
refreshDeps: [searchKey]
}
);
return ( return (
<> <>
@@ -223,7 +57,7 @@ const NodeTemplatesModal = ({ isOpen, onClose }: ModuleTemplateListProps) => {
fontSize={'sm'} fontSize={'sm'}
/> />
<MyBox <MyBox
isLoading={isLoading} isLoading={templatesIsLoading}
display={'flex'} display={'flex'}
zIndex={3} zIndex={3}
flexDirection={'column'} flexDirection={'column'}
@@ -241,22 +75,18 @@ const NodeTemplatesModal = ({ isOpen, onClose }: ModuleTemplateListProps) => {
userSelect={'none'} userSelect={'none'}
overflow={isOpen ? 'none' : 'hidden'} overflow={isOpen ? 'none' : 'hidden'}
> >
<RenderHeader <NodeTemplateListHeader
templateType={templateType}
onClose={onClose} onClose={onClose}
parentId={parentId} templateType={templateType}
onUpdateParentId={onUpdateParentId}
searchKey={searchKey}
loadNodeTemplates={loadNodeTemplates} loadNodeTemplates={loadNodeTemplates}
setSearchKey={setSearchKey} parentId={parentId || ''}
onUpdateParentId={onUpdateParentId}
/> />
<RenderList <NodeTemplateList
templateType={templateType} onAddNode={onAddNode}
templates={templates} templates={templates}
type={templateType} templateType={templateType}
onClose={onClose} onUpdateParentId={onUpdateParentId}
parentId={parentId}
setParentId={onUpdateParentId}
/> />
</MyBox> </MyBox>
</> </>
@@ -264,531 +94,3 @@ const NodeTemplatesModal = ({ isOpen, onClose }: ModuleTemplateListProps) => {
}; };
export default React.memo(NodeTemplatesModal); export default React.memo(NodeTemplatesModal);
const RenderHeader = React.memo(function RenderHeader({
templateType,
onClose,
parentId,
searchKey,
setSearchKey,
loadNodeTemplates,
onUpdateParentId
}: RenderHeaderProps) {
const { t } = useTranslation();
const { feConfigs } = useSystemStore();
const router = useRouter();
// Get paths
const { data: paths = [] } = useRequest2(
() => {
if (templateType === TemplateTypeEnum.teamPlugin)
return getAppFolderPath({ sourceId: parentId, type: 'current' });
return getSystemPluginPaths({ sourceId: parentId, type: 'current' });
},
{
manual: false,
refreshDeps: [parentId]
}
);
return (
<Box px={'5'} mb={3} whiteSpace={'nowrap'} overflow={'hidden'}>
{/* Tabs */}
<Flex flex={'1 0 0'} alignItems={'center'} gap={2}>
<Box flex={'1 0 0'}>
<FillRowTabs
list={[
{
icon: 'core/modules/basicNode',
label: t('common:core.module.template.Basic Node'),
value: TemplateTypeEnum.basic
},
{
icon: 'phoneTabbar/tool',
label: t('common:navbar.Toolkit'),
value: TemplateTypeEnum.systemPlugin
},
{
icon: 'core/modules/teamPlugin',
label: t('common:core.module.template.Team app'),
value: TemplateTypeEnum.teamPlugin
}
]}
width={'100%'}
py={'5px'}
value={templateType}
onChange={(e) => {
loadNodeTemplates({
type: e as TemplateTypeEnum,
parentId: ''
});
}}
/>
</Box>
{/* close icon */}
<IconButton
size={'sm'}
icon={<MyIcon name={'common/backFill'} w={'14px'} color={'myGray.600'} />}
bg={'myGray.100'}
_hover={{
bg: 'myGray.200',
'& svg': {
color: 'primary.600'
}
}}
variant={'grayBase'}
aria-label={''}
onClick={onClose}
/>
</Flex>
{/* Search */}
{(templateType === TemplateTypeEnum.teamPlugin ||
templateType === TemplateTypeEnum.systemPlugin) && (
<Flex mt={2} alignItems={'center'} h={10}>
<InputGroup mr={4} h={'full'}>
<InputLeftElement h={'full'} alignItems={'center'} display={'flex'}>
<MyIcon name={'common/searchLight'} w={'16px'} color={'myGray.500'} ml={3} />
</InputLeftElement>
<Input
h={'full'}
bg={'myGray.50'}
placeholder={
templateType === TemplateTypeEnum.teamPlugin
? t('common:plugin.Search_app')
: t('common:plugin.Search plugin')
}
onChange={(e) => setSearchKey(e.target.value)}
/>
</InputGroup>
<Box flex={1} />
{templateType === TemplateTypeEnum.teamPlugin && (
<Flex
alignItems={'center'}
cursor={'pointer'}
_hover={{
color: 'primary.600'
}}
fontSize={'sm'}
onClick={() => router.push('/dashboard/apps')}
gap={1}
>
<Box>{t('common:create')}</Box>
<MyIcon name={'common/rightArrowLight'} w={'0.8rem'} />
</Flex>
)}
{templateType === TemplateTypeEnum.systemPlugin && feConfigs.systemPluginCourseUrl && (
<Flex
alignItems={'center'}
cursor={'pointer'}
_hover={{
color: 'primary.600'
}}
fontSize={'sm'}
onClick={() => window.open(feConfigs.systemPluginCourseUrl)}
gap={1}
>
<Box>{t('common:plugin.contribute')}</Box>
<MyIcon name={'common/rightArrowLight'} w={'0.8rem'} />
</Flex>
)}
</Flex>
)}
{/* paths */}
{(templateType === TemplateTypeEnum.teamPlugin ||
templateType === TemplateTypeEnum.systemPlugin) &&
!searchKey &&
parentId && (
<Flex alignItems={'center'} mt={2}>
<FolderPath paths={paths} FirstPathDom={null} onClick={onUpdateParentId} />
</Flex>
)}
</Box>
);
});
const RenderList = React.memo(function RenderList({
templateType,
templates,
type,
onClose,
setParentId
}: RenderListProps) {
const { t } = useTranslation();
const { setLoading } = useSystemStore();
const { isPc } = useSystem();
const { screenToFlowPosition } = useReactFlow();
const { computedNewNodeName } = useWorkflowUtils();
const { toast } = useToast();
const setNodes = useContextSelector(WorkflowNodeEdgeContext, (v) => v.setNodes);
const nodeList = useContextSelector(WorkflowContext, (v) => v.nodeList);
const { data: pluginGroups = [] } = useRequest2(getPluginGroups, {
manual: false
});
const formatTemplatesArray = useMemo<{ list: NodeTemplateListType; label: string }[]>(() => {
const data = (() => {
if (type === TemplateTypeEnum.systemPlugin) {
return pluginGroups.map((group) => {
const copy: NodeTemplateListType = group.groupTypes.map((type) => ({
list: [],
type: type.typeId,
label: type.typeName
}));
templates.forEach((item) => {
const index = copy.findIndex((template) => template.type === item.templateType);
if (index === -1) return;
copy[index].list.push(item);
});
return {
label: group.groupName,
list: copy.filter((item) => item.list.length > 0)
};
});
}
const copy: NodeTemplateListType = cloneDeep(workflowNodeTemplateList);
templates.forEach((item) => {
const index = copy.findIndex((template) => template.type === item.templateType);
if (index === -1) return;
copy[index].list.push(item);
});
return [
{
label: '',
list: copy.filter((item) => item.list.length > 0)
}
];
})();
return data.filter(({ list }) => list.length > 0);
}, [type, templates, pluginGroups]);
const onAddNode = useMemoizedFn(
async ({
template,
position
}: {
template: NodeTemplateListItemType;
position: XYPosition;
}) => {
// Load template node
const templateNode = await (async () => {
try {
// get plugin preview module
if (AppNodeFlowNodeTypeMap[template.flowNodeType]) {
setLoading(true);
const res = await getPreviewPluginNode({ appId: template.id });
setLoading(false);
return res;
}
// base node
const baseTemplate = moduleTemplatesFlat.find((item) => item.id === template.id);
if (!baseTemplate) {
throw new Error('baseTemplate not found');
}
return { ...baseTemplate };
} catch (e) {
toast({
status: 'error',
title: getErrText(e, t('common:core.plugin.Get Plugin Module Detail Failed'))
});
setLoading(false);
return Promise.reject(e);
}
})();
const nodePosition = screenToFlowPosition(position);
const mouseX = nodePosition.x - 100;
const mouseY = nodePosition.y - 20;
// Add default values to some inputs
const defaultValueMap: Record<string, any> = {
[NodeInputKeyEnum.userChatInput]: undefined,
[NodeInputKeyEnum.fileUrlList]: undefined
};
nodeList.forEach((node) => {
if (node.flowNodeType === FlowNodeTypeEnum.workflowStart) {
defaultValueMap[NodeInputKeyEnum.userChatInput] = [
node.nodeId,
NodeOutputKeyEnum.userChatInput
];
defaultValueMap[NodeInputKeyEnum.fileUrlList] = [
[node.nodeId, NodeOutputKeyEnum.userFiles]
];
}
});
const newNode = nodeTemplate2FlowNode({
template: {
...templateNode,
name: computedNewNodeName({
templateName: t(templateNode.name as any),
flowNodeType: templateNode.flowNodeType,
pluginId: templateNode.pluginId
}),
intro: t(templateNode.intro as any),
inputs: templateNode.inputs
.filter((input) => input.deprecated !== true)
.map((input) => ({
...input,
value: defaultValueMap[input.key] ?? input.value,
valueDesc: t(input.valueDesc as any),
label: t(input.label as any),
description: t(input.description as any),
debugLabel: t(input.debugLabel as any),
toolDescription: t(input.toolDescription as any)
})),
outputs: templateNode.outputs
.filter((output) => output.deprecated !== true)
.map((output) => ({
...output,
valueDesc: t(output.valueDesc as any),
label: t(output.label as any),
description: t(output.description as any)
}))
},
position: { x: mouseX, y: mouseY },
selected: true,
t
});
const newNodes = [newNode];
if (templateNode.flowNodeType === FlowNodeTypeEnum.loop) {
const startNode = nodeTemplate2FlowNode({
template: LoopStartNode,
position: { x: mouseX + 60, y: mouseY + 280 },
parentNodeId: newNode.id,
t
});
const endNode = nodeTemplate2FlowNode({
template: LoopEndNode,
position: { x: mouseX + 420, y: mouseY + 680 },
parentNodeId: newNode.id,
t
});
newNodes.push(startNode, endNode);
}
setNodes((state) => {
const newState = state
.map((node) => ({
...node,
selected: false
}))
// @ts-ignore
.concat(newNodes);
return newState;
});
}
);
const gridStyle = useMemo(() => {
if (type === TemplateTypeEnum.teamPlugin) {
return {
gridTemplateColumns: ['1fr', '1fr'],
py: 2,
avatarSize: '2rem',
authorInName: false,
authorInRight: true
};
}
return {
gridTemplateColumns: ['1fr', '1fr 1fr'],
py: 3,
avatarSize: '1.75rem',
authorInName: true,
authorInRight: false
};
}, [type]);
const PluginListRender = useMemoizedFn(({ list = [] }: { list: NodeTemplateListType }) => {
return (
<>
{list.map((item, i) => {
return (
<Box
key={item.type}
css={css({
span: {
display: 'block'
}
})}
>
<Box fontSize={'sm'} my={2} fontWeight={'500'} flex={1} color={'myGray.900'}>
{t(item.label as any)}
</Box>
<Grid gridTemplateColumns={gridStyle.gridTemplateColumns} rowGap={2}>
{item.list.map((template) => {
return (
<MyTooltip
key={template.id}
placement={'right'}
label={
<Box py={2}>
<Flex alignItems={'center'}>
<MyAvatar
src={template.avatar}
w={'1.75rem'}
objectFit={'contain'}
borderRadius={'sm'}
/>
<Box fontWeight={'bold'} ml={3} color={'myGray.900'}>
{t(template.name as any)}
</Box>
</Flex>
<Box mt={2} color={'myGray.500'} maxH={'100px'} overflow={'hidden'}>
{t(template.intro as any) || t('common:core.workflow.Not intro')}
</Box>
{type === TemplateTypeEnum.systemPlugin && (
<CostTooltip
cost={template.currentCost}
hasTokenFee={template.hasTokenFee}
/>
)}
</Box>
}
>
<Flex
alignItems={'center'}
py={gridStyle.py}
px={3}
cursor={'pointer'}
_hover={{
bg: 'myWhite.600',
'& .arrowIcon': {
display: 'block'
}
}}
borderRadius={'sm'}
draggable={
!template.isFolder || template.flowNodeType === FlowNodeTypeEnum.toolSet
}
onDragEnd={(e) => {
if (e.clientX < sliderWidth) return;
onAddNode({
template,
position: { x: e.clientX, y: e.clientY }
});
}}
onClick={(e) => {
if (
template.isFolder &&
template.flowNodeType !== FlowNodeTypeEnum.toolSet
) {
return setParentId(template.id);
}
if (isPc) {
return onAddNode({
template,
position: { x: sliderWidth * 1.5, y: 200 }
});
}
onAddNode({
template,
position: { x: e.clientX, y: e.clientY }
});
onClose();
}}
whiteSpace={'nowrap'}
overflow={'hidden'}
textOverflow={'ellipsis'}
>
<MyAvatar
src={template.avatar}
w={gridStyle.avatarSize}
objectFit={'contain'}
borderRadius={'sm'}
flexShrink={0}
/>
<Box
color={'myGray.900'}
fontWeight={'500'}
fontSize={'sm'}
flex={'1 0 0'}
ml={3}
className="textEllipsis"
>
{t(template.name as any)}
</Box>
{template.isFolder && templateType === TemplateTypeEnum.teamPlugin && (
<Box
color={'myGray.500'}
_hover={{
bg: 'var(--light-general-surface-opacity-005, rgba(17, 24, 36, 0.05))',
color: 'primary.600'
}}
p={1}
rounded={'sm'}
className="arrowIcon"
display="none"
onClick={(e) => {
e.stopPropagation();
return setParentId(template.id);
}}
>
<MyIcon name="common/arrowRight" w={'24px'} />
</Box>
)}
{gridStyle.authorInRight && template.authorAvatar && template.author && (
<HStack spacing={1} maxW={'120px'} flexShrink={0}>
<MyAvatar src={template.authorAvatar} w={'1rem'} borderRadius={'50%'} />
<Box fontSize={'xs'} className="textEllipsis">
{template.author}
</Box>
</HStack>
)}
</Flex>
</MyTooltip>
);
})}
</Grid>
</Box>
);
})}
</>
);
});
return templates.length === 0 ? (
<EmptyTip text={t('app:module.No Modules')} />
) : (
<Box flex={'1 0 0'} overflow={'overlay'} px={formatTemplatesArray.length > 1 ? 2 : 5}>
<Accordion defaultIndex={[0]} allowMultiple reduceMotion>
{formatTemplatesArray.length > 1 ? (
<>
{formatTemplatesArray.map(({ list, label }, index) => (
<AccordionItem key={index} border={'none'}>
<AccordionButton
fontSize={'sm'}
fontWeight={'500'}
color={'myGray.900'}
justifyContent={'space-between'}
alignItems={'center'}
borderRadius={'md'}
px={3}
>
{t(label as any)}
<AccordionIcon />
</AccordionButton>
<AccordionPanel py={0}>
<PluginListRender list={list} />
</AccordionPanel>
</AccordionItem>
))}
</>
) : (
<PluginListRender list={formatTemplatesArray?.[0]?.list} />
)}
</Accordion>
</Box>
);
});

View File

@@ -0,0 +1,137 @@
import MyBox from '@fastgpt/web/components/common/MyBox';
import React, { useMemo } from 'react';
import { useContextSelector } from 'use-context-selector';
import { EDGE_TYPE, FlowNodeTypeEnum } from '@fastgpt/global/core/workflow/node/constant';
import type { FlowNodeItemType } from '@fastgpt/global/core/workflow/type/node';
import { type Node, useReactFlow } from 'reactflow';
import { WorkflowInitContext, WorkflowNodeEdgeContext } from '../context/workflowInitContext';
import { useMemoizedFn } from 'ahooks';
import NodeTemplateListHeader from './components/NodeTemplates/header';
import NodeTemplateList from './components/NodeTemplates/list';
import { Popover, PopoverContent, PopoverBody } from '@chakra-ui/react';
import { WorkflowEventContext } from '../context/workflowEventContext';
import { useNodeTemplates } from './components/NodeTemplates/useNodeTemplates';
import { getNanoid } from '@fastgpt/global/common/string/tools';
import { popoverHeight, popoverWidth } from './hooks/useWorkflow';
const NodeTemplatesPopover = () => {
const handleParams = useContextSelector(WorkflowEventContext, (v) => v.handleParams);
const setHandleParams = useContextSelector(WorkflowEventContext, (v) => v.setHandleParams);
const setNodes = useContextSelector(WorkflowNodeEdgeContext, (v) => v.setNodes);
const setEdges = useContextSelector(WorkflowNodeEdgeContext, (v) => v.setEdges);
const {
templateType,
parentId,
templatesIsLoading,
templates,
loadNodeTemplates,
onUpdateParentId
} = useNodeTemplates();
const onAddNode = useMemoizedFn(async ({ newNodes }: { newNodes: Node<FlowNodeItemType>[] }) => {
setNodes((state) => {
const newState = state
.map((node) => ({
...node,
selected: false
}))
// @ts-ignore
.concat(newNodes);
return newState;
});
if (!handleParams) return;
const isToolHandle = handleParams?.handleId === 'selectedTools';
const newEdges = newNodes
.filter((node) => {
// Exclude nodes that don't meet the conditions
// 1. Tool set nodes must be connected through tool handle
if (!isToolHandle && node.data.flowNodeType === FlowNodeTypeEnum.toolSet) {
return false;
}
// 2. Exclude loop start and end nodes
if (
[FlowNodeTypeEnum.loopStart, FlowNodeTypeEnum.loopEnd].includes(node.data.flowNodeType)
) {
return false;
}
// 3. Tool handle can only connect to tool nodes
if (isToolHandle && !node.data.isTool) {
return false;
}
return true;
})
.map((node) => ({
id: getNanoid(),
source: handleParams.nodeId as string,
sourceHandle: handleParams.handleId,
target: node.id,
targetHandle: isToolHandle ? 'selectedTools' : `${node.id}-target-left`,
type: EDGE_TYPE
}));
setEdges((state) => {
const newState = state.concat(newEdges);
return newState;
});
setHandleParams(null);
});
if (!handleParams) return null;
return (
<Popover
isOpen={!!handleParams}
onClose={() => setHandleParams(null)}
closeOnBlur={true}
closeOnEsc={true}
autoFocus={true}
isLazy
>
<PopoverContent
position="fixed"
top={`${handleParams.popoverPosition.y}px`}
left={`${handleParams.popoverPosition.x + 10}px`}
width={popoverWidth}
height={popoverHeight}
boxShadow="3px 0 20px rgba(0,0,0,0.2)"
border={'none'}
>
<PopoverBody padding={0} h={'full'}>
<MyBox
isLoading={templatesIsLoading}
display={'flex'}
flexDirection={'column'}
py={4}
h={'full'}
userSelect="none"
>
<NodeTemplateListHeader
isPopover={true}
templateType={templateType}
loadNodeTemplates={loadNodeTemplates}
parentId={parentId || ''}
onUpdateParentId={onUpdateParentId}
/>
<NodeTemplateList
onAddNode={onAddNode}
isPopover={true}
templates={templates}
templateType={templateType}
onUpdateParentId={onUpdateParentId}
/>
</MyBox>
</PopoverBody>
</PopoverContent>
</Popover>
);
};
export default React.memo(NodeTemplatesPopover);

View File

@@ -93,24 +93,6 @@ const ButtonEdge = (props: EdgeProps) => {
newTargetY: targetY newTargetY: targetY
}; };
} }
if (targetPosition === 'right') {
return {
newTargetX: targetX + 3,
newTargetY: targetY
};
}
if (targetPosition === 'bottom') {
return {
newTargetX: targetX,
newTargetY: targetY + 3
};
}
if (targetPosition === 'top') {
return {
newTargetX: targetX,
newTargetY: targetY - 3
};
}
return { return {
newTargetX: targetX, newTargetX: targetX,
newTargetY: targetY newTargetY: targetY
@@ -150,6 +132,7 @@ const ButtonEdge = (props: EdgeProps) => {
return `translate(-50%, -90%) translate(${newTargetX}px,${newTargetY}px) rotate(90deg)`; return `translate(-50%, -90%) translate(${newTargetX}px,${newTargetY}px) rotate(90deg)`;
} }
})(); })();
return ( return (
<EdgeLabelRenderer> <EdgeLabelRenderer>
<Box hidden={parentNode?.isFolded}> <Box hidden={parentNode?.isFolded}>
@@ -160,8 +143,8 @@ const ButtonEdge = (props: EdgeProps) => {
position={'absolute'} position={'absolute'}
transform={`translate(-55%, -50%) translate(${labelX}px,${labelY}px)`} transform={`translate(-55%, -50%) translate(${labelX}px,${labelY}px)`}
pointerEvents={'all'} pointerEvents={'all'}
w={'18px'} w={'26px'}
h={'18px'} h={'26px'}
bg={'white'} bg={'white'}
borderRadius={'18px'} borderRadius={'18px'}
cursor={'pointer'} cursor={'pointer'}
@@ -177,8 +160,8 @@ const ButtonEdge = (props: EdgeProps) => {
position={'absolute'} position={'absolute'}
transform={arrowTransform} transform={arrowTransform}
pointerEvents={'all'} pointerEvents={'all'}
w={highlightEdge ? '12px' : '10px'} w={highlightEdge ? '14px' : '12px'}
h={highlightEdge ? '12px' : '10px'} h={highlightEdge ? '14px' : '12px'}
zIndex={highlightEdge ? defaultZIndex + 1000 : defaultZIndex} zIndex={highlightEdge ? defaultZIndex + 1000 : defaultZIndex}
> >
<MyIcon <MyIcon
@@ -197,8 +180,8 @@ const ButtonEdge = (props: EdgeProps) => {
highlightEdge, highlightEdge,
labelX, labelX,
labelY, labelY,
isToolEdge,
defaultZIndex, defaultZIndex,
isToolEdge,
edgeColor, edgeColor,
targetPosition, targetPosition,
newTargetX, newTargetX,

View File

@@ -0,0 +1,200 @@
import React, { useEffect, useState } from 'react';
import { Box, Flex, IconButton, Input, InputGroup, InputLeftElement } from '@chakra-ui/react';
import FillRowTabs from '@fastgpt/web/components/common/Tabs/FillRowTabs';
import MyIcon from '@fastgpt/web/components/common/Icon';
import { useTranslation } from 'react-i18next';
import { useSystemStore } from '@/web/common/system/useSystemStore';
import { useRouter } from 'next/router';
import { useRequest2 } from '@fastgpt/web/hooks/useRequest';
import { getSystemPluginPaths } from '@/web/core/app/api/plugin';
import { getAppFolderPath } from '@/web/core/app/api/app';
import FolderPath from '@/components/common/folder/Path';
export enum TemplateTypeEnum {
'basic' = 'basic',
'systemPlugin' = 'systemPlugin',
'teamPlugin' = 'teamPlugin'
}
export type NodeTemplateListHeaderProps = {
onClose?: () => void;
isPopover?: boolean;
templateType: TemplateTypeEnum;
parentId: string;
loadNodeTemplates: (params: {
parentId?: string;
type?: TemplateTypeEnum;
searchVal?: string;
}) => Promise<void>;
onUpdateParentId: (parentId: string) => void;
};
const NodeTemplateListHeader = ({
onClose,
isPopover = false,
templateType,
parentId,
loadNodeTemplates,
onUpdateParentId
}: NodeTemplateListHeaderProps) => {
const { t } = useTranslation();
const { feConfigs } = useSystemStore();
const router = useRouter();
const [searchKey, setSearchKey] = useState('');
useEffect(() => {
setSearchKey('');
}, [templateType]);
useEffect(() => {
loadNodeTemplates({
type: templateType,
parentId: '',
searchVal: searchKey
});
}, [searchKey, loadNodeTemplates, templateType]);
// Get paths
const { data: paths = [] } = useRequest2(
() => {
if (templateType === TemplateTypeEnum.teamPlugin)
return getAppFolderPath({ sourceId: parentId, type: 'current' });
return getSystemPluginPaths({ sourceId: parentId, type: 'current' });
},
{
manual: false,
refreshDeps: [parentId]
}
);
return (
<Box px={'5'} mb={3} whiteSpace={'nowrap'} overflow={'hidden'}>
{/* Tabs */}
<Flex flex={'1 0 0'} alignItems={'center'} gap={2}>
<Box flex={'1 0 0'}>
<FillRowTabs
list={[
{
icon: 'core/modules/basicNode',
label: t('common:core.module.template.Basic Node'),
value: TemplateTypeEnum.basic
},
{
icon: 'phoneTabbar/tool',
label: t('common:navbar.Toolkit'),
value: TemplateTypeEnum.systemPlugin
},
{
icon: 'core/modules/teamPlugin',
label: t('common:core.module.template.Team app'),
value: TemplateTypeEnum.teamPlugin
}
]}
width={'100%'}
py={isPopover ? '3px' : '5px'}
{...(isPopover
? {
iconSize: '14px',
labelSize: '12.8px',
iconGap: 1
}
: {})}
value={templateType}
onChange={(e) => {
loadNodeTemplates({
type: e as TemplateTypeEnum,
parentId: ''
});
}}
/>
</Box>
{/* close icon */}
{!isPopover && (
<IconButton
size={'sm'}
icon={<MyIcon name={'common/backFill'} w={'14px'} color={'myGray.600'} />}
bg={'myGray.100'}
_hover={{
bg: 'myGray.200',
'& svg': {
color: 'primary.600'
}
}}
variant={'grayBase'}
aria-label={''}
onClick={onClose}
/>
)}
</Flex>
{/* Search */}
{(templateType === TemplateTypeEnum.teamPlugin ||
templateType === TemplateTypeEnum.systemPlugin) && (
<Flex mt={2} alignItems={'center'} h={isPopover ? 8 : 10}>
<InputGroup h={'full'}>
<InputLeftElement h={'full'} alignItems={'center'} display={'flex'}>
<MyIcon name={'common/searchLight'} w={'16px'} color={'myGray.500'} ml={3} />
</InputLeftElement>
<Input
h={'full'}
bg={'myGray.50'}
placeholder={
templateType === TemplateTypeEnum.teamPlugin
? t('common:plugin.Search_app')
: t('common:plugin.Search plugin')
}
value={searchKey}
onChange={(e) => setSearchKey(e.target.value)}
/>
</InputGroup>
<Box flex={1} />
{!isPopover && templateType === TemplateTypeEnum.teamPlugin && (
<Flex
alignItems={'center'}
cursor={'pointer'}
_hover={{
color: 'primary.600'
}}
fontSize={'sm'}
onClick={() => router.push('/dashboard/apps')}
gap={1}
ml={4}
>
<Box>{t('common:create')}</Box>
<MyIcon name={'common/rightArrowLight'} w={'0.8rem'} />
</Flex>
)}
{!isPopover &&
templateType === TemplateTypeEnum.systemPlugin &&
feConfigs.systemPluginCourseUrl && (
<Flex
alignItems={'center'}
cursor={'pointer'}
_hover={{
color: 'primary.600'
}}
fontSize={'sm'}
onClick={() => window.open(feConfigs.systemPluginCourseUrl)}
gap={1}
ml={4}
>
<Box>{t('common:plugin.contribute')}</Box>
<MyIcon name={'common/rightArrowLight'} w={'0.8rem'} />
</Flex>
)}
</Flex>
)}
{/* paths */}
{(templateType === TemplateTypeEnum.teamPlugin ||
templateType === TemplateTypeEnum.systemPlugin) &&
!searchKey &&
parentId && (
<Flex alignItems={'center'} mt={2}>
<FolderPath paths={paths} FirstPathDom={null} onClick={onUpdateParentId} />
</Flex>
)}
</Box>
);
};
export default React.memo(NodeTemplateListHeader);

View File

@@ -0,0 +1,461 @@
import React, { useMemo } from 'react';
import {
Accordion,
AccordionButton,
AccordionIcon,
AccordionItem,
AccordionPanel,
Box,
Grid,
Flex,
HStack,
css,
useToast
} from '@chakra-ui/react';
import { useTranslation } from 'react-i18next';
import { useRequest2 } from '@fastgpt/web/hooks/useRequest';
import { getPluginGroups, getPreviewPluginNode } from '@/web/core/app/api/plugin';
import EmptyTip from '@fastgpt/web/components/common/EmptyTip';
import type {
FlowNodeItemType,
NodeTemplateListItemType,
NodeTemplateListType
} from '@fastgpt/global/core/workflow/type/node';
import { TemplateTypeEnum } from './header';
import { useMemoizedFn } from 'ahooks';
import MyIcon from '@fastgpt/web/components/common/Icon';
import MyAvatar from '@fastgpt/web/components/common/Avatar';
import MyTooltip from '@fastgpt/web/components/common/MyTooltip';
import CostTooltip from '@/components/core/app/plugin/CostTooltip';
import {
FlowNodeTypeEnum,
AppNodeFlowNodeTypeMap
} from '@fastgpt/global/core/workflow/node/constant';
import { useContextSelector } from 'use-context-selector';
import { WorkflowContext } from '../../../context';
import { cloneDeep } from 'lodash';
import { workflowNodeTemplateList } from '@fastgpt/web/core/workflow/constants';
import { sliderWidth } from '../../NodeTemplatesModal';
import { getErrText } from '@fastgpt/global/common/error/utils';
import { useWorkflowUtils } from '../../hooks/useUtils';
import { moduleTemplatesFlat } from '@fastgpt/global/core/workflow/template/constants';
import { LoopStartNode } from '@fastgpt/global/core/workflow/template/system/loop/loopStart';
import { LoopEndNode } from '@fastgpt/global/core/workflow/template/system/loop/loopEnd';
import { useReactFlow, type Node } from 'reactflow';
import { NodeInputKeyEnum, NodeOutputKeyEnum } from '@fastgpt/global/core/workflow/constants';
import { nodeTemplate2FlowNode } from '@/web/core/workflow/utils';
import { WorkflowEventContext } from '../../../context/workflowEventContext';
export type TemplateListProps = {
onAddNode: ({ newNodes }: { newNodes: Node<FlowNodeItemType>[] }) => void;
isPopover?: boolean;
templates: NodeTemplateListItemType[];
templateType: TemplateTypeEnum;
onUpdateParentId: (parentId: string) => void;
};
const NodeTemplateListItem = ({
template,
templateType,
handleAddNode,
isPopover,
onUpdateParentId
}: {
template: NodeTemplateListItemType;
templateType: TemplateTypeEnum;
handleAddNode: (e: {
template: NodeTemplateListItemType;
position: { x: number; y: number };
}) => void;
isPopover?: boolean;
onUpdateParentId: (parentId: string) => void;
}) => {
const { t } = useTranslation();
const { screenToFlowPosition } = useReactFlow();
const handleParams = useContextSelector(WorkflowEventContext, (v) => v.handleParams);
return (
<MyTooltip
placement={'right'}
label={
<Box py={2}>
<Flex alignItems={'center'}>
<MyAvatar
src={template.avatar}
w={'1.75rem'}
objectFit={'contain'}
borderRadius={'sm'}
/>
<Box fontWeight={'bold'} ml={3} color={'myGray.900'}>
{t(template.name as any)}
</Box>
</Flex>
<Box mt={2} color={'myGray.500'} maxH={'100px'} overflow={'hidden'}>
{t(template.intro as any) || t('common:core.workflow.Not intro')}
</Box>
{templateType === TemplateTypeEnum.systemPlugin && (
<CostTooltip cost={template.currentCost} hasTokenFee={template.hasTokenFee} />
)}
</Box>
}
shouldWrapChildren={false}
>
<Flex
w={'100%'}
alignItems={'center'}
py={isPopover ? 2 : 3}
px={isPopover ? 2 : 3}
cursor={'pointer'}
_hover={{
bg: 'myWhite.600',
'& .arrowIcon': {
display: 'flex'
}
}}
borderRadius={'sm'}
whiteSpace={'nowrap'}
overflow={'hidden'}
textOverflow={'ellipsis'}
draggable={
!isPopover && (!template.isFolder || template.flowNodeType === FlowNodeTypeEnum.toolSet)
}
onDragEnd={(e) => {
if (e.clientX < sliderWidth) return;
const nodePosition = screenToFlowPosition({ x: e.clientX, y: e.clientY });
handleAddNode({
template,
position: { x: nodePosition.x - 100, y: nodePosition.y - 20 }
});
}}
onClick={() => {
if (template.isFolder && template.flowNodeType !== FlowNodeTypeEnum.toolSet) {
onUpdateParentId(template.id);
return;
}
const position =
isPopover && handleParams
? handleParams.addNodePosition
: screenToFlowPosition({ x: sliderWidth * 1.5, y: 200 });
handleAddNode({ template, position });
}}
>
<MyAvatar
src={template.avatar}
w={isPopover ? '1.5rem' : '1.75rem'}
objectFit={'contain'}
borderRadius={'sm'}
flexShrink={0}
/>
<Box
color={'myGray.900'}
fontWeight={'500'}
fontSize={isPopover ? 'xs' : 'sm'}
flex={'1 0 0'}
ml={3}
className="textEllipsis"
>
{t(template.name as any)}
</Box>
{/* Folder right arrow */}
{template.isFolder && templateType === TemplateTypeEnum.teamPlugin && (
<Box
color={'myGray.500'}
_hover={{
bg: 'var(--light-general-surface-opacity-005, rgba(17, 24, 36, 0.05))',
color: 'primary.600'
}}
p={1}
rounded={'sm'}
className="arrowIcon"
display="none"
onClick={(e) => {
e.stopPropagation();
onUpdateParentId(template.id);
}}
>
<MyIcon name="common/arrowRight" w={isPopover ? '16px' : '20px'} />
</Box>
)}
{/* Author */}
{!isPopover && template.authorAvatar && template.author && (
<HStack spacing={1} maxW={'120px'} flexShrink={0}>
<MyAvatar src={template.authorAvatar} w={'1rem'} borderRadius={'50%'} />
<Box fontSize={'xs'} className="textEllipsis">
{template.author}
</Box>
</HStack>
)}
</Flex>
</MyTooltip>
);
};
const NodeTemplateList = ({
onAddNode,
isPopover = false,
templates,
templateType,
onUpdateParentId
}: TemplateListProps) => {
const { t } = useTranslation();
const toast = useToast();
const { computedNewNodeName } = useWorkflowUtils();
const nodeList = useContextSelector(WorkflowContext, (v) => v.nodeList);
const handleParams = useContextSelector(WorkflowEventContext, (v) => v.handleParams);
const { data: pluginGroups = [] } = useRequest2(getPluginGroups, {
manual: false
});
const handleAddNode = useMemoizedFn(
async ({
template,
position
}: {
template: NodeTemplateListItemType;
position: { x: number; y: number };
}) => {
if (template.isFolder && template.flowNodeType !== FlowNodeTypeEnum.toolSet) {
return;
}
try {
const templateNode = await (async () => {
try {
if (AppNodeFlowNodeTypeMap[template.flowNodeType]) {
const res = await getPreviewPluginNode({ appId: template.id });
return res;
}
const baseTemplate = moduleTemplatesFlat.find((item) => item.id === template.id);
if (!baseTemplate) {
throw new Error('baseTemplate not found');
}
return { ...baseTemplate };
} catch (e) {
toast({
status: 'error',
title: getErrText(e, t('common:core.plugin.Get Plugin Module Detail Failed'))
});
return Promise.reject(e);
}
})();
const defaultValueMap: Record<string, any> = {
[NodeInputKeyEnum.userChatInput]: undefined,
[NodeInputKeyEnum.fileUrlList]: undefined
};
nodeList.forEach((node) => {
if (node.flowNodeType === FlowNodeTypeEnum.workflowStart) {
defaultValueMap[NodeInputKeyEnum.userChatInput] = [
node.nodeId,
NodeOutputKeyEnum.userChatInput
];
defaultValueMap[NodeInputKeyEnum.fileUrlList] = [
[node.nodeId, NodeOutputKeyEnum.userFiles]
];
}
});
const currentNode = nodeList.find((node) => node.nodeId === handleParams?.nodeId);
const newNode = nodeTemplate2FlowNode({
template: {
...templateNode,
name: computedNewNodeName({
templateName: t(templateNode.name as any),
flowNodeType: templateNode.flowNodeType,
pluginId: templateNode.pluginId
}),
intro: t(templateNode.intro as any),
inputs: templateNode.inputs
.filter((input) => input.deprecated !== true)
.map((input) => ({
...input,
value: defaultValueMap[input.key] ?? input.value,
valueDesc: t(input.valueDesc as any),
label: t(input.label as any),
description: t(input.description as any),
debugLabel: t(input.debugLabel as any),
toolDescription: t(input.toolDescription as any)
})),
outputs: templateNode.outputs
.filter((output) => output.deprecated !== true)
.map((output) => ({
...output,
valueDesc: t(output.valueDesc as any),
label: t(output.label as any),
description: t(output.description as any)
}))
},
position,
selected: true,
parentNodeId: currentNode?.parentNodeId,
t
});
const newNodes = [newNode];
if (templateNode.flowNodeType === FlowNodeTypeEnum.loop) {
const startNode = nodeTemplate2FlowNode({
template: LoopStartNode,
position: { x: position.x + 60, y: position.y + 280 },
parentNodeId: newNode.id,
t
});
const endNode = nodeTemplate2FlowNode({
template: LoopEndNode,
position: { x: position.x + 420, y: position.y + 680 },
parentNodeId: newNode.id,
t
});
newNodes.push(startNode, endNode);
}
if (newNodes && newNodes.length > 0) {
onAddNode({
newNodes
});
}
} catch (error) {
console.error('Failed to create node template:', error);
}
}
);
const formatTemplatesArray = useMemoizedFn(
(
type: TemplateTypeEnum,
templates: NodeTemplateListItemType[],
pluginGroups: any[]
): { list: NodeTemplateListType; label: string }[] => {
const data = (() => {
if (type === TemplateTypeEnum.systemPlugin) {
return pluginGroups.map((group) => {
const copy: NodeTemplateListType = group.groupTypes.map((type: any) => ({
list: [],
type: type.typeId,
label: type.typeName
}));
templates.forEach((item) => {
const index = copy.findIndex((template) => template.type === item.templateType);
if (index === -1) return;
copy[index].list.push(item);
});
return {
label: group.groupName,
list: copy.filter((item) => item.list.length > 0)
};
});
}
const copy: NodeTemplateListType = cloneDeep(workflowNodeTemplateList);
templates.forEach((item) => {
const index = copy.findIndex((template) => template.type === item.templateType);
if (index === -1) return;
copy[index].list.push(item);
});
return [
{
label: '',
list: copy.filter((item) => item.list.length > 0)
}
];
})();
return data.filter(({ list }) => list.length > 0);
}
);
const formatTemplatesArrayData = useMemo(
() => formatTemplatesArray(templateType, templates, pluginGroups),
[templateType, templates, pluginGroups, formatTemplatesArray]
);
const PluginListRender = useMemoizedFn(({ list = [] }: { list: NodeTemplateListType }) => {
return (
<>
{list.map((item) => {
return (
<Box
key={item.type}
css={css({
span: {
display: 'block'
}
})}
>
<Box
fontSize={isPopover ? '12.8px' : 'sm'}
my={2}
fontWeight={'500'}
flex={1}
color={isPopover ? 'myGray.600' : 'myGray.900'}
>
{t(item.label as any)}
</Box>
<Grid
gridTemplateColumns={
templateType === TemplateTypeEnum.teamPlugin ? ['1fr'] : ['1fr', '1fr 1fr']
}
rowGap={2}
>
{item.list.map((template) => (
<NodeTemplateListItem
key={template.id}
template={template}
templateType={templateType}
handleAddNode={handleAddNode}
isPopover={isPopover}
onUpdateParentId={onUpdateParentId}
/>
))}
</Grid>
</Box>
);
})}
</>
);
});
return templates.length === 0 ? (
<EmptyTip text={t('app:module.No Modules')} />
) : (
<Box flex={'1 0 0'} overflow={'overlay'} px={formatTemplatesArrayData.length > 1 ? 2 : 5}>
<Accordion defaultIndex={[0]} allowMultiple reduceMotion>
{formatTemplatesArrayData.length > 1 ? (
<>
{formatTemplatesArrayData.map(({ list, label }, index) => (
<AccordionItem key={index} border={'none'}>
<AccordionButton
fontSize={'sm'}
fontWeight={'500'}
color={'myGray.900'}
justifyContent={'space-between'}
alignItems={'center'}
borderRadius={'md'}
px={3}
>
{t(label as any)}
<AccordionIcon />
</AccordionButton>
<AccordionPanel py={0}>
<PluginListRender list={list} />
</AccordionPanel>
</AccordionItem>
))}
</>
) : (
<PluginListRender list={formatTemplatesArrayData?.[0]?.list} />
)}
</Accordion>
</Box>
);
};
export default React.memo(NodeTemplateList);

View File

@@ -0,0 +1,136 @@
import { useState, useMemo, useCallback } from 'react';
import { useRequest2 } from '@fastgpt/web/hooks/useRequest';
import { useSystemStore } from '@/web/common/system/useSystemStore';
import type { NodeTemplateListItemType } from '@fastgpt/global/core/workflow/type/node';
import { FlowNodeTypeEnum } from '@fastgpt/global/core/workflow/node/constant';
import { getTeamPlugTemplates, getSystemPlugTemplates } from '@/web/core/app/api/plugin';
import { TemplateTypeEnum } from './header';
import { useContextSelector } from 'use-context-selector';
import { WorkflowContext } from '../../../context';
import type { ParentIdType } from '@fastgpt/global/common/parentFolder/type';
export const useNodeTemplates = () => {
const { feConfigs } = useSystemStore();
const [templateType, setTemplateType] = useState(TemplateTypeEnum.basic);
const [parentId, setParentId] = useState<ParentIdType>('');
const basicNodeTemplates = useContextSelector(WorkflowContext, (v) => v.basicNodeTemplates);
const appId = useContextSelector(WorkflowContext, (state) => state.appId || '');
const nodeList = useContextSelector(WorkflowContext, (v) => v.nodeList);
const hasToolNode = useMemo(
() => nodeList.some((node) => node.flowNodeType === FlowNodeTypeEnum.toolSet),
[nodeList]
);
const { data: basicNodes } = useRequest2(
async () => {
if (templateType === TemplateTypeEnum.basic) {
return basicNodeTemplates
.filter((item) => {
// unique node filter
if (item.unique) {
const nodeExist = nodeList.some((node) => node.flowNodeType === item.flowNodeType);
if (nodeExist) {
return false;
}
}
// special node filter
if (item.flowNodeType === FlowNodeTypeEnum.lafModule && !feConfigs.lafEnv) {
return false;
}
// tool stop or tool params
if (
!hasToolNode &&
(item.flowNodeType === FlowNodeTypeEnum.stopTool ||
item.flowNodeType === FlowNodeTypeEnum.toolParams)
) {
return false;
}
return true;
})
.map<NodeTemplateListItemType>((item) => ({
id: item.id,
flowNodeType: item.flowNodeType,
templateType: item.templateType,
avatar: item.avatar,
name: item.name,
intro: item.intro
}));
}
},
{
manual: false,
throttleWait: 100,
refreshDeps: [basicNodeTemplates, nodeList, hasToolNode, templateType]
}
);
const {
data: teamAndSystemApps,
loading: templatesIsLoading,
runAsync
} = useRequest2(
async ({
parentId = '',
type = templateType,
searchVal = ''
}: {
parentId?: ParentIdType;
type?: TemplateTypeEnum;
searchVal?: string;
}) => {
if (type === TemplateTypeEnum.teamPlugin) {
return getTeamPlugTemplates({
parentId,
searchKey: searchVal
}).then((res) => res.filter((app) => app.id !== appId));
}
if (type === TemplateTypeEnum.systemPlugin) {
return getSystemPlugTemplates({
searchKey: searchVal,
parentId
});
}
},
{
onSuccess(res, [{ parentId = '', type = templateType }]) {
setParentId(parentId);
setTemplateType(type);
},
refreshDeps: [templateType]
}
);
const loadNodeTemplates = useCallback(
async (params: { parentId?: ParentIdType; type?: TemplateTypeEnum; searchVal?: string }) => {
await runAsync(params);
},
[runAsync]
);
const onUpdateParentId = useCallback(
(parentId: ParentIdType) => {
loadNodeTemplates({
parentId
});
},
[loadNodeTemplates]
);
const templates = useMemo(() => {
if (templateType === TemplateTypeEnum.basic) {
return basicNodes || [];
}
return teamAndSystemApps || [];
}, [basicNodes, teamAndSystemApps, templateType]);
return {
templateType,
parentId,
templatesIsLoading,
templates,
loadNodeTemplates,
onUpdateParentId
};
};

View File

@@ -266,6 +266,9 @@ const computeHelperLines = (
); );
}; };
export const popoverWidth = 400;
export const popoverHeight = 600;
export const useWorkflow = () => { export const useWorkflow = () => {
const { toast } = useToast(); const { toast } = useToast();
const { t } = useTranslation(); const { t } = useTranslation();
@@ -289,8 +292,9 @@ export const useWorkflow = () => {
WorkflowStatusContext, WorkflowStatusContext,
(v) => v.resetParentNodeSizeAndPosition (v) => v.resetParentNodeSizeAndPosition
); );
const setHandleParams = useContextSelector(WorkflowEventContext, (v) => v.setHandleParams);
const { getIntersectingNodes } = useReactFlow(); const { getIntersectingNodes, flowToScreenPosition, getZoom } = useReactFlow();
const { isDowningCtrl } = useKeyboard(); const { isDowningCtrl } = useKeyboard();
/* helper line */ /* helper line */
@@ -375,6 +379,61 @@ export const useWorkflow = () => {
} }
}); });
const getTemplatesListPopoverPosition = useMemoizedFn(({ nodeId }: { nodeId: string | null }) => {
const node = nodes.find((n) => n.id === nodeId);
if (!node) return { x: 0, y: 0 };
const position = flowToScreenPosition({
x: node.position.x,
y: node.position.y
});
const zoom = getZoom();
let x = position.x + (node.width || 0) * zoom;
let y = position.y;
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
const margin = 20;
// Check right boundary
if (x + popoverWidth + margin > viewportWidth) {
x = Math.max(margin, position.x + (node.width || 0) * zoom - popoverWidth - 30);
}
// Check bottom boundary
if (y + popoverHeight + margin > viewportHeight) {
y = Math.max(margin, viewportHeight - popoverHeight - margin);
}
// Check top boundary
if (y < margin) {
y = margin;
}
return { x, y };
});
const getAddNodePosition = useMemoizedFn(
({ nodeId, handleId }: { nodeId: string | null; handleId: string | null }) => {
const node = nodes.find((n) => n.id === nodeId);
if (!node) return { x: 0, y: 0 };
if (handleId === 'selectedTools') {
return {
x: node.position.x,
y: node.position.y + (node.height || 0) + 80
};
}
return {
x: node.position.x + (node.width || 0) + 120,
y: node.position.y
};
}
);
/* node */ /* node */
// Remove change node and its child nodes and edges // Remove change node and its child nodes and edges
const handleRemoveNode = useMemoizedFn((change: NodeRemoveChange, nodeId: string) => { const handleRemoveNode = useMemoizedFn((change: NodeRemoveChange, nodeId: string) => {
@@ -525,21 +584,61 @@ export const useWorkflow = () => {
/* connect */ /* connect */
const onConnectStart = useCallback( const onConnectStart = useCallback(
(event: any, params: OnConnectStartParams) => { (event: any, params: OnConnectStartParams) => {
if (!params.nodeId) return; const { nodeId, handleId } = params;
if (!nodeId) return;
// If node is folded, unfold it when connecting // If node is folded, unfold it when connecting
const sourceNode = nodeList.find((node) => node.nodeId === params.nodeId); const sourceNode = nodeList.find((node) => node.nodeId === nodeId);
if (sourceNode?.isFolded) { if (sourceNode?.isFolded) {
return onChangeNode({ onChangeNode({
nodeId: params.nodeId, nodeId,
type: 'attr', type: 'attr',
key: 'isFolded', key: 'isFolded',
value: false value: false
}); });
} }
setConnectingEdge(params); setConnectingEdge(params);
// Check connect or click(If the mouse position remains basically unchanged, it indicates a click)
if (params.handleId) {
const initialX = event.clientX;
const initialY = event.clientY;
const startTime = Date.now();
const handleMouseUp = (moveEvent: MouseEvent) => {
document.removeEventListener('mouseup', handleMouseUp);
const currentX = moveEvent.clientX;
const currentY = moveEvent.clientY;
const endTime = Date.now();
const pressDuration = endTime - startTime;
if (
Math.abs(currentX - initialX) <= 5 &&
Math.abs(currentY - initialY) <= 5 &&
pressDuration < 500
) {
const popoverPosition = getTemplatesListPopoverPosition({ nodeId });
const addNodePosition = getAddNodePosition({ nodeId, handleId });
setHandleParams({
...params,
popoverPosition,
addNodePosition
});
}
};
document.addEventListener('mouseup', handleMouseUp);
}
}, },
[nodeList, setConnectingEdge, onChangeNode] [
nodeList,
setConnectingEdge,
onChangeNode,
getTemplatesListPopoverPosition,
getAddNodePosition,
setHandleParams
]
); );
const onConnectEnd = useCallback(() => { const onConnectEnd = useCallback(() => {
setConnectingEdge(undefined); setConnectingEdge(undefined);
@@ -629,7 +728,6 @@ export const useWorkflow = () => {
}, },
[setMenu] [setMenu]
); );
const onPaneClick = useCallback(() => { const onPaneClick = useCallback(() => {
setMenu(null); setMenu(null);
}, [setMenu]); }, [setMenu]);

View File

@@ -19,6 +19,7 @@ import FlowController from './components/FlowController';
import ContextMenu from './components/ContextMenu'; import ContextMenu from './components/ContextMenu';
import { WorkflowNodeEdgeContext, WorkflowInitContext } from '../context/workflowInitContext'; import { WorkflowNodeEdgeContext, WorkflowInitContext } from '../context/workflowInitContext';
import { WorkflowEventContext } from '../context/workflowEventContext'; import { WorkflowEventContext } from '../context/workflowEventContext';
import NodeTemplatesPopover from './NodeTemplatesPopover';
const NodeSimple = dynamic(() => import('./nodes/NodeSimple')); const NodeSimple = dynamic(() => import('./nodes/NodeSimple'));
const nodeTypes: Record<FlowNodeTypeEnum, any> = { const nodeTypes: Record<FlowNodeTypeEnum, any> = {
@@ -127,6 +128,7 @@ const Workflow = () => {
}} }}
/> />
<NodeTemplatesModal isOpen={isOpenTemplate} onClose={onCloseTemplate} /> <NodeTemplatesModal isOpen={isOpenTemplate} onClose={onCloseTemplate} />
<NodeTemplatesPopover />
</> </>
<ReactFlow <ReactFlow

View File

@@ -12,7 +12,7 @@ import { useTranslation } from 'next-i18next';
import MyTooltip from '@fastgpt/web/components/common/MyTooltip'; import MyTooltip from '@fastgpt/web/components/common/MyTooltip';
import { type FlowNodeInputItemType } from '@fastgpt/global/core/workflow/type/io.d'; import { type FlowNodeInputItemType } from '@fastgpt/global/core/workflow/type/io.d';
import { getNanoid } from '@fastgpt/global/common/string/tools'; import { getNanoid } from '@fastgpt/global/common/string/tools';
import { SourceHandle } from './render/Handle'; import { MySourceHandle } from './render/Handle';
import { getHandleId } from '@fastgpt/global/core/workflow/utils'; import { getHandleId } from '@fastgpt/global/core/workflow/utils';
import { useContextSelector } from 'use-context-selector'; import { useContextSelector } from 'use-context-selector';
import { WorkflowContext } from '../../context'; import { WorkflowContext } from '../../context';
@@ -95,7 +95,7 @@ const NodeCQNode = ({ data, selected }: NodeProps<FlowNodeItemType>) => {
}); });
}} }}
/> />
<SourceHandle <MySourceHandle
nodeId={nodeId} nodeId={nodeId}
handleId={getHandleId(nodeId, 'source', item.key)} handleId={getHandleId(nodeId, 'source', item.key)}
position={Position.Right} position={Position.Right}

View File

@@ -26,7 +26,7 @@ import { WorkflowContext } from '../../../context';
import MySelect from '@fastgpt/web/components/common/MySelect'; import MySelect from '@fastgpt/web/components/common/MySelect';
import MyInput from '@/components/MyInput'; import MyInput from '@/components/MyInput';
import { getElseIFLabel, getHandleId } from '@fastgpt/global/core/workflow/utils'; import { getElseIFLabel, getHandleId } from '@fastgpt/global/core/workflow/utils';
import { SourceHandle } from '../render/Handle'; import { MySourceHandle } from '../render/Handle';
import { Position, useReactFlow } from 'reactflow'; import { Position, useReactFlow } from 'reactflow';
import { getRefData } from '@/web/core/workflow/utils'; import { getRefData } from '@/web/core/workflow/utils';
import DragIcon from '@fastgpt/web/components/common/DndDrag/DragIcon'; import DragIcon from '@fastgpt/web/components/common/DndDrag/DragIcon';
@@ -261,7 +261,7 @@ const ListItem = ({
</Button> </Button>
</Container> </Container>
{!snapshot.isDragging && ( {!snapshot.isDragging && (
<SourceHandle <MySourceHandle
nodeId={nodeId} nodeId={nodeId}
handleId={handleId} handleId={handleId}
position={Position.Right} position={Position.Right}

View File

@@ -10,7 +10,7 @@ import { useContextSelector } from 'use-context-selector';
import { WorkflowContext } from '../../../context'; import { WorkflowContext } from '../../../context';
import Container from '../../components/Container'; import Container from '../../components/Container';
import DndDrag, { Draggable } from '@fastgpt/web/components/common/DndDrag/index'; import DndDrag, { Draggable } from '@fastgpt/web/components/common/DndDrag/index';
import { SourceHandle } from '../render/Handle'; import { MySourceHandle } from '../render/Handle';
import { getHandleId } from '@fastgpt/global/core/workflow/utils'; import { getHandleId } from '@fastgpt/global/core/workflow/utils';
import ListItem from './ListItem'; import ListItem from './ListItem';
import { IfElseResultEnum } from '@fastgpt/global/core/workflow/template/system/ifElse/constant'; import { IfElseResultEnum } from '@fastgpt/global/core/workflow/template/system/ifElse/constant';
@@ -95,7 +95,7 @@ const NodeIfElse = ({ data, selected }: NodeProps<FlowNodeItemType>) => {
<Box color={'black'} fontSize={'md'} ml={2}> <Box color={'black'} fontSize={'md'} ml={2}>
{IfElseResultEnum.ELSE} {IfElseResultEnum.ELSE}
</Box> </Box>
<SourceHandle <MySourceHandle
nodeId={nodeId} nodeId={nodeId}
handleId={elseHandleId} handleId={elseHandleId}
position={Position.Right} position={Position.Right}

View File

@@ -11,7 +11,7 @@ import { useTranslation } from 'next-i18next';
import MyTooltip from '@fastgpt/web/components/common/MyTooltip'; import MyTooltip from '@fastgpt/web/components/common/MyTooltip';
import { type FlowNodeInputItemType } from '@fastgpt/global/core/workflow/type/io.d'; import { type FlowNodeInputItemType } from '@fastgpt/global/core/workflow/type/io.d';
import { getNanoid } from '@fastgpt/global/common/string/tools'; import { getNanoid } from '@fastgpt/global/common/string/tools';
import { SourceHandle } from './render/Handle'; import { MySourceHandle } from './render/Handle';
import { getHandleId } from '@fastgpt/global/core/workflow/utils'; import { getHandleId } from '@fastgpt/global/core/workflow/utils';
import { useContextSelector } from 'use-context-selector'; import { useContextSelector } from 'use-context-selector';
import { WorkflowContext } from '../../context'; import { WorkflowContext } from '../../context';
@@ -207,7 +207,7 @@ const OptionItem = ({
}} }}
/> />
{!snapshot.isDragging && ( {!snapshot.isDragging && (
<SourceHandle <MySourceHandle
nodeId={nodeId} nodeId={nodeId}
handleId={getHandleId(nodeId, 'source', item.key)} handleId={getHandleId(nodeId, 'source', item.key)}
position={Position.Right} position={Position.Right}

View File

@@ -1,6 +1,6 @@
import React, { useMemo } from 'react'; import React, { useMemo } from 'react';
import { Position } from 'reactflow'; import { Position } from 'reactflow';
import { SourceHandle, TargetHandle } from '.'; import { MySourceHandle, MyTargetHandle } from '.';
import { getHandleId } from '@fastgpt/global/core/workflow/utils'; import { getHandleId } from '@fastgpt/global/core/workflow/utils';
import { NodeOutputKeyEnum } from '@fastgpt/global/core/workflow/constants'; import { NodeOutputKeyEnum } from '@fastgpt/global/core/workflow/constants';
import { useContextSelector } from 'use-context-selector'; import { useContextSelector } from 'use-context-selector';
@@ -18,7 +18,7 @@ export const ConnectionSourceHandle = ({
const edges = useContextSelector(WorkflowNodeEdgeContext, (v) => v.edges); const edges = useContextSelector(WorkflowNodeEdgeContext, (v) => v.edges);
const { connectingEdge, nodeList } = useContextSelector(WorkflowContext, (ctx) => ctx); const { connectingEdge, nodeList } = useContextSelector(WorkflowContext, (ctx) => ctx);
const { showSourceHandle, RightHandle, LeftHandlee, TopHandlee, BottomHandlee } = useMemo(() => { const { showSourceHandle, RightHandle } = useMemo(() => {
const node = nodeList.find((node) => node.nodeId === nodeId); const node = nodeList.find((node) => node.nodeId === nodeId);
/* not node/not connecting node, hidden */ /* not node/not connecting node, hidden */
@@ -49,7 +49,7 @@ export const ConnectionSourceHandle = ({
return null; return null;
return ( return (
<SourceHandle <MySourceHandle
nodeId={nodeId} nodeId={nodeId}
handleId={handleId} handleId={handleId}
position={Position.Right} position={Position.Right}
@@ -57,80 +57,14 @@ export const ConnectionSourceHandle = ({
/> />
); );
})(); })();
const LeftHandlee = (() => {
const leftTargetConnected = edges.some(
(edge) => edge.targetHandle === getHandleId(nodeId, 'target', Position.Left)
);
if (!node || !node?.sourceHandle?.left || leftTargetConnected) return null;
const handleId = getHandleId(nodeId, 'source', Position.Left);
return (
<SourceHandle
nodeId={nodeId}
handleId={handleId}
position={Position.Left}
translate={[-8, 0]}
/>
);
})();
const TopHandlee = (() => {
if (
edges.some(
(edge) => edge.target === nodeId && edge.targetHandle === NodeOutputKeyEnum.selectedTools
)
)
return null;
const handleId = getHandleId(nodeId, 'source', Position.Top);
const topTargetConnected = edges.some(
(edge) => edge.targetHandle === getHandleId(nodeId, 'target', Position.Top)
);
if (!node || !node?.sourceHandle?.top || topTargetConnected) return null;
return (
<SourceHandle
nodeId={nodeId}
handleId={handleId}
position={Position.Top}
translate={[0, -5]}
/>
);
})();
const BottomHandlee = (() => {
const handleId = getHandleId(nodeId, 'source', Position.Bottom);
const targetConnected = edges.some(
(edge) => edge.targetHandle === getHandleId(nodeId, 'target', Position.Bottom)
);
if (!node || !node?.sourceHandle?.bottom || targetConnected) return null;
return (
<SourceHandle
nodeId={nodeId}
handleId={handleId}
position={Position.Bottom}
translate={[0, 5]}
/>
);
})();
return { return {
showSourceHandle, showSourceHandle,
RightHandle, RightHandle
LeftHandlee,
TopHandlee,
BottomHandlee
}; };
}, [connectingEdge, edges, nodeId, nodeList, isFoldNode]); }, [connectingEdge, edges, nodeId, nodeList, isFoldNode]);
return showSourceHandle ? ( return showSourceHandle ? <>{RightHandle}</> : null;
<>
{RightHandle}
{LeftHandlee}
{TopHandlee}
{BottomHandlee}
</>
) : null;
}; };
export const ConnectionTargetHandle = React.memo(function ConnectionTargetHandle({ export const ConnectionTargetHandle = React.memo(function ConnectionTargetHandle({
@@ -141,7 +75,7 @@ export const ConnectionTargetHandle = React.memo(function ConnectionTargetHandle
const edges = useContextSelector(WorkflowNodeEdgeContext, (v) => v.edges); const edges = useContextSelector(WorkflowNodeEdgeContext, (v) => v.edges);
const { connectingEdge, nodeList } = useContextSelector(WorkflowContext, (ctx) => ctx); const { connectingEdge, nodeList } = useContextSelector(WorkflowContext, (ctx) => ctx);
const { LeftHandle, rightHandle, topHandle, bottomHandle } = useMemo(() => { const { LeftHandle } = useMemo(() => {
let node: FlowNodeItemType | undefined = undefined, let node: FlowNodeItemType | undefined = undefined,
connectingNode: FlowNodeItemType | undefined = undefined; connectingNode: FlowNodeItemType | undefined = undefined;
for (const item of nodeList) { for (const item of nodeList) {
@@ -196,7 +130,7 @@ export const ConnectionTargetHandle = React.memo(function ConnectionTargetHandle
const handleId = getHandleId(nodeId, 'target', Position.Left); const handleId = getHandleId(nodeId, 'target', Position.Left);
return ( return (
<TargetHandle <MyTargetHandle
nodeId={nodeId} nodeId={nodeId}
handleId={handleId} handleId={handleId}
position={Position.Left} position={Position.Left}
@@ -205,69 +139,14 @@ export const ConnectionTargetHandle = React.memo(function ConnectionTargetHandle
/> />
); );
})(); })();
const rightHandle = (() => {
if (!node || !node?.targetHandle?.right) return null;
const handleId = getHandleId(nodeId, 'target', Position.Right);
return (
<TargetHandle
nodeId={nodeId}
handleId={handleId}
position={Position.Right}
translate={[4, 0]}
showHandle={showHandle}
/>
);
})();
const topHandle = (() => {
if (!node || !node?.targetHandle?.top) return null;
const handleId = getHandleId(nodeId, 'target', Position.Top);
return (
<TargetHandle
nodeId={nodeId}
handleId={handleId}
position={Position.Top}
translate={[0, -5]}
showHandle={showHandle}
/>
);
})();
const bottomHandle = (() => {
if (!node || !node?.targetHandle?.bottom) return null;
const handleId = getHandleId(nodeId, 'target', Position.Bottom);
return (
<TargetHandle
nodeId={nodeId}
handleId={handleId}
position={Position.Bottom}
translate={[0, 5]}
showHandle={showHandle}
/>
);
})();
return { return {
showHandle, showHandle,
LeftHandle, LeftHandle
rightHandle,
topHandle,
bottomHandle
}; };
}, [connectingEdge, edges, nodeId, nodeList]); }, [connectingEdge, edges, nodeId, nodeList]);
return ( return <>{LeftHandle}</>;
<>
{LeftHandle}
{rightHandle}
{topHandle}
{bottomHandle}
</>
);
}); });
export default function Dom() { export default function Dom() {

View File

@@ -7,26 +7,30 @@ import { useCallback, useMemo } from 'react';
import { useContextSelector } from 'use-context-selector'; import { useContextSelector } from 'use-context-selector';
import { WorkflowContext } from '@/pageComponents/app/detail/WorkflowComponents/context'; import { WorkflowContext } from '@/pageComponents/app/detail/WorkflowComponents/context';
import { WorkflowNodeEdgeContext } from '../../../../context/workflowInitContext'; import { WorkflowNodeEdgeContext } from '../../../../context/workflowInitContext';
import { WorkflowEventContext } from '../../../../context/workflowEventContext';
const handleSize = '16px'; const handleSize = '16px';
const activeHandleSize = '20px';
const handleId = NodeOutputKeyEnum.selectedTools;
type ToolHandleProps = BoxProps & { type ToolHandleProps = BoxProps & {
nodeId: string; nodeId: string;
show: boolean; show: boolean;
}; };
export const ToolTargetHandle = ({ show, nodeId }: ToolHandleProps) => { export const ToolTargetHandle = ({ show, nodeId }: ToolHandleProps) => {
const { t } = useTranslation(); const toolConnecting = useContextSelector(
const connectingEdge = useContextSelector(WorkflowContext, (ctx) => ctx.connectingEdge); WorkflowContext,
const edges = useContextSelector(WorkflowNodeEdgeContext, (v) => v.edges); (ctx) => ctx.connectingEdge?.handleId === NodeOutputKeyEnum.selectedTools
);
const handleId = NodeOutputKeyEnum.selectedTools; const connected = useContextSelector(WorkflowNodeEdgeContext, (v) =>
v.edges.some((edge) => edge.target === nodeId && edge.targetHandle === handleId)
const connected = edges.some((edge) => edge.target === nodeId && edge.targetHandle === handleId); );
const active = show && toolConnecting;
// if top handle is connected, return null // if top handle is connected, return null
const showHandle = connectingEdge const showHandle = active || connected;
? show && connectingEdge.handleId === NodeOutputKeyEnum.selectedTools
: connected; const size = active ? activeHandleSize : handleSize;
const Render = useMemo(() => { const Render = useMemo(() => {
return ( return (
@@ -35,8 +39,8 @@ export const ToolTargetHandle = ({ show, nodeId }: ToolHandleProps) => {
borderRadius: '0', borderRadius: '0',
backgroundColor: 'transparent', backgroundColor: 'transparent',
border: 'none', border: 'none',
width: handleSize, width: size,
height: handleSize, height: size,
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
@@ -50,8 +54,8 @@ export const ToolTargetHandle = ({ show, nodeId }: ToolHandleProps) => {
> >
<Box <Box
className="flow-handle" className="flow-handle"
w={handleSize} w={size}
h={handleSize} h={size}
border={'4px solid #8774EE'} border={'4px solid #8774EE'}
rounded={'xs'} rounded={'xs'}
bg={'white'} bg={'white'}
@@ -60,14 +64,21 @@ export const ToolTargetHandle = ({ show, nodeId }: ToolHandleProps) => {
/> />
</Handle> </Handle>
); );
}, [handleId, showHandle]); }, [showHandle, size]);
return Render; return Render;
}; };
export const ToolSourceHandle = () => { export const ToolSourceHandle = ({ nodeId }: { nodeId: string }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const setEdges = useContextSelector(WorkflowNodeEdgeContext, (v) => v.setEdges); const setEdges = useContextSelector(WorkflowNodeEdgeContext, (v) => v.setEdges);
const connectingEdge = useContextSelector(
WorkflowContext,
(ctx) => ctx.connectingEdge?.nodeId === nodeId
);
const nodeIsHover = useContextSelector(WorkflowEventContext, (v) => v.hoverNodeId === nodeId);
const active = useMemo(() => nodeIsHover || connectingEdge, [nodeIsHover, connectingEdge]);
/* onConnect edge, delete tool input and switch */ /* onConnect edge, delete tool input and switch */
const onConnect = useCallback( const onConnect = useCallback(
@@ -83,6 +94,8 @@ export const ToolSourceHandle = () => {
[setEdges] [setEdges]
); );
const size = active ? activeHandleSize : handleSize;
const Render = useMemo(() => { const Render = useMemo(() => {
return ( return (
<MyTooltip label={t('common:core.workflow.tool.Handle')} shouldWrapChildren={false}> <MyTooltip label={t('common:core.workflow.tool.Handle')} shouldWrapChildren={false}>
@@ -91,8 +104,8 @@ export const ToolSourceHandle = () => {
borderRadius: '0', borderRadius: '0',
backgroundColor: 'transparent', backgroundColor: 'transparent',
border: 'none', border: 'none',
width: handleSize, width: size,
height: handleSize, height: size,
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
@@ -104,8 +117,8 @@ export const ToolSourceHandle = () => {
onConnect={onConnect} onConnect={onConnect}
> >
<Box <Box
w={handleSize} w={size}
h={handleSize} h={size}
border={'4px solid #8774EE'} border={'4px solid #8774EE'}
rounded={'xs'} rounded={'xs'}
bg={'white'} bg={'white'}
@@ -115,7 +128,7 @@ export const ToolSourceHandle = () => {
</Handle> </Handle>
</MyTooltip> </MyTooltip>
); );
}, [onConnect, t]); }, [onConnect, size, t]);
return Render; return Render;
}; };

View File

@@ -1,6 +1,5 @@
import React, { useMemo } from 'react'; import React, { useMemo } from 'react';
import { Handle, Position } from 'reactflow'; import { Handle, Position } from 'reactflow';
import { handleHighLightStyle, sourceCommonStyle, handleConnectedStyle, handleSize } from './style';
import { NodeOutputKeyEnum } from '@fastgpt/global/core/workflow/constants'; import { NodeOutputKeyEnum } from '@fastgpt/global/core/workflow/constants';
import { useContextSelector } from 'use-context-selector'; import { useContextSelector } from 'use-context-selector';
import { WorkflowContext } from '../../../../context'; import { WorkflowContext } from '../../../../context';
@@ -10,6 +9,37 @@ import {
WorkflowInitContext WorkflowInitContext
} from '../../../../context/workflowInitContext'; } from '../../../../context/workflowInitContext';
import { WorkflowEventContext } from '../../../../context/workflowEventContext'; import { WorkflowEventContext } from '../../../../context/workflowEventContext';
import MyTooltip from '@fastgpt/web/components/common/MyTooltip';
import { useTranslation } from 'react-i18next';
import { Box, Flex } from '@chakra-ui/react';
const handleSizeConnected = 16;
const handleSizeConnecting = 30;
const handleAddIconSize = 22;
const sourceCommonStyle = {
backgroundColor: 'white',
borderRadius: '50%'
};
const handleConnectedStyle = {
...sourceCommonStyle,
borderWidth: '3px',
borderColor: '#94B5FF',
width: handleSizeConnected,
height: handleSizeConnected
};
const handleHighLightStyle = {
...sourceCommonStyle,
borderWidth: '4px',
borderColor: '#487FFF',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: handleSizeConnecting,
height: handleSizeConnecting
};
type Props = { type Props = {
nodeId: string; nodeId: string;
@@ -18,23 +48,21 @@ type Props = {
translate?: [number, number]; translate?: [number, number];
}; };
const MySourceHandle = React.memo(function MySourceHandle({ export const MySourceHandle = React.memo(function MySourceHandle({
nodeId, nodeId,
translate, translate,
handleId, handleId,
position, position
highlightStyle, }: Props) {
connectedStyle const { t } = useTranslation();
}: Props & {
highlightStyle: Record<string, any>;
connectedStyle: Record<string, any>;
}) {
const edges = useContextSelector(WorkflowNodeEdgeContext, (v) => v.edges); const edges = useContextSelector(WorkflowNodeEdgeContext, (v) => v.edges);
const connectingEdge = useContextSelector(WorkflowContext, (ctx) => ctx.connectingEdge); const connectingEdge = useContextSelector(WorkflowContext, (ctx) => ctx.connectingEdge);
const nodes = useContextSelector(WorkflowInitContext, (v) => v.nodes); const node = useContextSelector(WorkflowInitContext, (v) =>
v.nodes.find((node) => node.data.nodeId === nodeId)
);
const hoverNodeId = useContextSelector(WorkflowEventContext, (v) => v.hoverNodeId); const hoverNodeId = useContextSelector(WorkflowEventContext, (v) => v.hoverNodeId);
const node = useMemo(() => nodes.find((node) => node.data.nodeId === nodeId), [nodes, nodeId]);
const connected = edges.some((edge) => edge.sourceHandle === handleId); const connected = edges.some((edge) => edge.sourceHandle === handleId);
const nodeFolded = node?.data.isFolded && edges.some((edge) => edge.source === nodeId); const nodeFolded = node?.data.isFolded && edges.some((edge) => edge.source === nodeId);
const nodeIsHover = hoverNodeId === nodeId; const nodeIsHover = hoverNodeId === nodeId;
@@ -46,32 +74,16 @@ const MySourceHandle = React.memo(function MySourceHandle({
const translateStr = useMemo(() => { const translateStr = useMemo(() => {
if (!translate) return ''; if (!translate) return '';
if (position === Position.Right) { if (position === Position.Right) {
return `${active ? translate[0] + 2 : translate[0]}px, -50%`; return `${active ? translate[0] + 6 : translate[0]}px, -50%`;
}
if (position === Position.Left) {
return `${active ? translate[0] + 2 : translate[0]}px, -50%`;
}
if (position === Position.Top) {
return `-50%, ${active ? translate[1] - 2 : translate[1]}px`;
}
if (position === Position.Bottom) {
return `-50%, ${active ? translate[1] + 2 : translate[1]}px`;
} }
}, [active, position, translate]); }, [active, position, translate]);
const transform = useMemo(
() => (translateStr ? `translate(${translateStr})` : ''),
[translateStr]
);
const { styles, showAddIcon } = useMemo(() => { const { styles, showAddIcon } = useMemo(() => {
if (active) { if (active) {
return { return {
styles: { styles: {
...highlightStyle, ...handleHighLightStyle,
...(translateStr && { transform: `${translateStr ? `translate(${translateStr})` : ''}`
transform
})
}, },
showAddIcon: true showAddIcon: true
}; };
@@ -80,33 +92,42 @@ const MySourceHandle = React.memo(function MySourceHandle({
if (connected || nodeFolded) { if (connected || nodeFolded) {
return { return {
styles: { styles: {
...connectedStyle, ...handleConnectedStyle,
...(translateStr && { transform: `${translateStr ? `translate(${translateStr})` : ''}`
transform
})
}, },
showAddIcon: false showAddIcon: false
}; };
} }
return { return {
styles: undefined, styles: {
visibility: 'hidden' as const
},
showAddIcon: false showAddIcon: false
}; };
}, [active, connected, nodeFolded, highlightStyle, translateStr, transform, connectedStyle]); }, [active, connected, nodeFolded, translateStr]);
const RenderHandle = useMemo(() => { if (!node) return null;
return ( if (connectingEdge?.handleId === NodeOutputKeyEnum.selectedTools) return null;
return (
<MyTooltip
label={
<Box>
<Flex>
<Box color={'myGray.900'}>{t('workflow:Click')}</Box>
<Box color={'myGray.600'}>{t('workflow:to_add_node')}</Box>
</Flex>
<Flex>
<Box color={'myGray.900'}>{t('workflow:Drag')}</Box>
<Box color={'myGray.600'}>{t('workflow:to_connect_node')}</Box>
</Flex>
</Box>
}
shouldWrapChildren={false}
>
<Handle <Handle
style={ style={styles}
!!styles
? styles
: {
visibility: 'hidden',
transform,
...handleSize
}
}
type="source" type="source"
id={handleId} id={handleId}
position={position} position={position}
@@ -117,125 +138,73 @@ const MySourceHandle = React.memo(function MySourceHandle({
name={'edgeAdd'} name={'edgeAdd'}
color={'primary.500'} color={'primary.500'}
pointerEvents={'none'} pointerEvents={'none'}
w={'14px'} w={`${handleAddIconSize}px`}
h={'14px'} h={`${handleAddIconSize}px`}
/> />
)} )}
</Handle> </Handle>
); </MyTooltip>
}, [handleId, position, showAddIcon, styles, transform]); );
if (!node) return null;
if (connectingEdge?.handleId === NodeOutputKeyEnum.selectedTools) return null;
return <>{RenderHandle}</>;
}); });
export const SourceHandle = (props: Props) => { export const MyTargetHandle = React.memo(function MyTargetHandle({
return (
<MySourceHandle
{...props}
highlightStyle={{ ...sourceCommonStyle, ...handleHighLightStyle }}
connectedStyle={{ ...sourceCommonStyle, ...handleConnectedStyle }}
/>
);
};
const MyTargetHandle = React.memo(function MyTargetHandle({
nodeId, nodeId,
handleId, handleId,
position, position,
translate, translate,
highlightStyle,
connectedStyle,
showHandle showHandle
}: Props & { }: Props & {
showHandle: boolean; showHandle: boolean;
highlightStyle: Record<string, any>;
connectedStyle: Record<string, any>;
}) { }) {
const edges = useContextSelector(WorkflowNodeEdgeContext, (v) => v.edges); const connected = useContextSelector(WorkflowNodeEdgeContext, (v) =>
v.edges.some((edge) => edge.targetHandle === handleId)
);
const connectingEdge = useContextSelector(WorkflowContext, (ctx) => ctx.connectingEdge); const connectingEdge = useContextSelector(WorkflowContext, (ctx) => ctx.connectingEdge);
const connected = edges.some((edge) => edge.targetHandle === handleId);
const translateStr = useMemo(() => { const translateStr = useMemo(() => {
if (!translate) return ''; if (!translate) return '';
if (position === Position.Right) {
return `${connectingEdge ? translate[0] + 2 : translate[0]}px, -50%`;
}
if (position === Position.Left) { if (position === Position.Left) {
return `${connectingEdge ? translate[0] - 2 : translate[0]}px, -50%`; return `${connectingEdge ? translate[0] - 6 : translate[0]}px, -50%`;
}
if (position === Position.Top) {
return `-50%, ${connectingEdge ? translate[1] - 2 : translate[1]}px`;
}
if (position === Position.Bottom) {
return `-50%, ${connectingEdge ? translate[1] + 2 : translate[1]}px`;
} }
}, [connectingEdge, position, translate]); }, [connectingEdge, position, translate]);
const transform = useMemo(
() => (translateStr ? `translate(${translateStr})` : ''),
[translateStr]
);
const styles = useMemo(() => { const styles = useMemo(() => {
if (!connectingEdge && !connected) return; if ((!connectingEdge && !connected) || !showHandle) {
return {
visibility: 'hidden' as const
};
}
if (connectingEdge) { if (connectingEdge) {
return { return {
...highlightStyle, ...handleHighLightStyle,
transform transform: `${translateStr ? `translate(${translateStr})` : ''}`
}; };
} }
if (connected) { if (connected) {
return { return {
...connectedStyle, ...handleConnectedStyle,
transform transform: `${translateStr ? `translate(${translateStr})` : ''}`
}; };
} }
return; return {
}, [connected, connectingEdge, connectedStyle, highlightStyle, transform]); visibility: 'hidden' as const
};
}, [connected, connectingEdge, showHandle, translateStr]);
const RenderHandle = useMemo(() => {
return (
<Handle
style={
styles && showHandle
? styles
: {
visibility: 'hidden',
transform,
...handleSize
}
}
isConnectableEnd={styles && showHandle}
type="target"
id={handleId}
position={position}
></Handle>
);
}, [styles, showHandle, transform, handleId, position]);
return RenderHandle;
});
export const TargetHandle = (
props: Props & {
showHandle: boolean;
}
) => {
return ( return (
<MyTargetHandle <Handle
{...props} style={styles}
highlightStyle={{ ...sourceCommonStyle, ...handleHighLightStyle }} isConnectableEnd={styles && showHandle}
connectedStyle={{ ...sourceCommonStyle, ...handleConnectedStyle }} isConnectableStart={false}
type="target"
id={handleId}
position={position}
/> />
); );
}; });
export default function Dom() { export default function Dom() {
return <></>; return <></>;

View File

@@ -1,29 +0,0 @@
export const primaryColor = '#487FFF';
export const lowPrimaryColor = '#94B5FF';
export const handleSize = {
width: '20px',
height: '20px'
};
export const sourceCommonStyle = {
backgroundColor: 'white',
borderWidth: '3px',
borderRadius: '50%'
};
export const handleConnectedStyle = {
borderColor: lowPrimaryColor,
width: '16px',
height: '16px'
};
export const handleHighLightStyle = {
borderColor: primaryColor,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '20px',
height: '20px'
};
export default function Dom() {
return <></>;
}

View File

@@ -308,8 +308,9 @@ const NodeCard = (props: Props) => {
); );
}, [nodeId, isFolded]); }, [nodeId, isFolded]);
const RenderToolHandle = useMemo( const RenderToolHandle = useMemo(
() => (node?.flowNodeType === FlowNodeTypeEnum.tools ? <ToolSourceHandle /> : null), () =>
[node?.flowNodeType] node?.flowNodeType === FlowNodeTypeEnum.tools ? <ToolSourceHandle nodeId={nodeId} /> : null,
[node?.flowNodeType, nodeId]
); );
return ( return (

View File

@@ -3,7 +3,7 @@ import React from 'react';
import { useTranslation } from 'next-i18next'; import { useTranslation } from 'next-i18next';
import { Box, Flex } from '@chakra-ui/react'; import { Box, Flex } from '@chakra-ui/react';
import { FlowNodeOutputTypeEnum } from '@fastgpt/global/core/workflow/node/constant'; import { FlowNodeOutputTypeEnum } from '@fastgpt/global/core/workflow/node/constant';
import { SourceHandle } from '../Handle'; import { MySourceHandle } from '../Handle';
import { getHandleId } from '@fastgpt/global/core/workflow/utils'; import { getHandleId } from '@fastgpt/global/core/workflow/utils';
import { Position } from 'reactflow'; import { Position } from 'reactflow';
import QuestionTip from '@fastgpt/web/components/common/MyTooltip/QuestionTip'; import QuestionTip from '@fastgpt/web/components/common/MyTooltip/QuestionTip';
@@ -74,7 +74,7 @@ const OutputLabel = ({ nodeId, output }: { nodeId: string; output: FlowNodeOutpu
)} )}
</Flex> </Flex>
{output.type === FlowNodeOutputTypeEnum.source && ( {output.type === FlowNodeOutputTypeEnum.source && (
<SourceHandle <MySourceHandle
nodeId={nodeId} nodeId={nodeId}
handleId={getHandleId(nodeId, 'source', output.key)} handleId={getHandleId(nodeId, 'source', output.key)}
translate={[34, 0]} translate={[34, 0]}

View File

@@ -2,7 +2,7 @@ import { postWorkflowDebug } from '@/web/core/workflow/api';
import { import {
checkWorkflowNodeAndConnection, checkWorkflowNodeAndConnection,
compareSnapshot, compareSnapshot,
storeEdgesRenderEdge, storeEdge2RenderEdge,
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';
@@ -861,7 +861,7 @@ const WorkflowContextProvider = ({
}); });
const onSwitchCloudVersion = useMemoizedFn((appVersion: AppVersionSchemaType) => { const onSwitchCloudVersion = useMemoizedFn((appVersion: AppVersionSchemaType) => {
const nodes = appVersion.nodes.map((item) => storeNode2FlowNode({ item, t })); const nodes = appVersion.nodes.map((item) => storeNode2FlowNode({ item, t }));
const edges = appVersion.edges.map((item) => storeEdgesRenderEdge({ edge: item })); const edges = appVersion.edges.map((item) => storeEdge2RenderEdge({ edge: item }));
const chatConfig = appVersion.chatConfig; const chatConfig = appVersion.chatConfig;
resetSnapshot({ resetSnapshot({
@@ -912,7 +912,7 @@ const WorkflowContextProvider = ({
isInit?: boolean isInit?: boolean
) => { ) => {
const nodes = e.nodes?.map((item) => storeNode2FlowNode({ item, t })) || []; const nodes = e.nodes?.map((item) => storeNode2FlowNode({ item, t })) || [];
const edges = e.edges?.map((item) => storeEdgesRenderEdge({ edge: item })) || []; const edges = e.edges?.map((item) => storeEdge2RenderEdge({ edge: item })) || [];
// Get storage snapshot兼容旧版正在编辑的用户刷新后会把 local 数据存到内存并删除 // Get storage snapshot兼容旧版正在编辑的用户刷新后会把 local 数据存到内存并删除
const pastSnapshot = (() => { const pastSnapshot = (() => {

View File

@@ -2,6 +2,12 @@ import React, { type ReactNode, useEffect, useMemo, useRef, useState } from 'rea
import { createContext } from 'use-context-selector'; import { createContext } from 'use-context-selector';
import { useLocalStorageState } from 'ahooks'; import { useLocalStorageState } from 'ahooks';
import { type SetState } from 'ahooks/lib/createUseStorageState'; import { type SetState } from 'ahooks/lib/createUseStorageState';
import type { OnConnectStartParams } from 'reactflow';
type handleParamsType = OnConnectStartParams & {
popoverPosition: { x: number; y: number };
addNodePosition: { x: number; y: number };
};
type WorkflowEventContextType = { type WorkflowEventContextType = {
mouseInCanvas: boolean; mouseInCanvas: boolean;
@@ -20,6 +26,8 @@ type WorkflowEventContextType = {
// version history // version history
showHistoryModal: boolean; showHistoryModal: boolean;
setShowHistoryModal: React.Dispatch<React.SetStateAction<boolean>>; setShowHistoryModal: React.Dispatch<React.SetStateAction<boolean>>;
handleParams: handleParamsType | null;
setHandleParams: React.Dispatch<React.SetStateAction<handleParamsType | null>>;
}; };
export const WorkflowEventContext = createContext<WorkflowEventContextType>({ export const WorkflowEventContext = createContext<WorkflowEventContextType>({
@@ -42,6 +50,10 @@ export const WorkflowEventContext = createContext<WorkflowEventContextType>({
showHistoryModal: false, showHistoryModal: false,
setShowHistoryModal: function (value: React.SetStateAction<boolean>): void { setShowHistoryModal: function (value: React.SetStateAction<boolean>): void {
throw new Error('Function not implemented.'); throw new Error('Function not implemented.');
},
handleParams: null,
setHandleParams: function (value: React.SetStateAction<handleParamsType | null>): void {
throw new Error('Function not implemented.');
} }
}); });
@@ -49,6 +61,8 @@ const WorkflowEventContextProvider = ({ children }: { children: ReactNode }) =>
// Watch mouse in canvas // Watch mouse in canvas
const reactFlowWrapper = useRef<HTMLDivElement>(null); const reactFlowWrapper = useRef<HTMLDivElement>(null);
const [mouseInCanvas, setMouseInCanvas] = useState(false); const [mouseInCanvas, setMouseInCanvas] = useState(false);
const [handleParams, setHandleParams] = useState<handleParamsType | null>(null);
useEffect(() => { useEffect(() => {
const handleMouseInCanvas = (e: MouseEvent) => { const handleMouseInCanvas = (e: MouseEvent) => {
setMouseInCanvas(true); setMouseInCanvas(true);
@@ -62,7 +76,7 @@ const WorkflowEventContextProvider = ({ children }: { children: ReactNode }) =>
reactFlowWrapper?.current?.removeEventListener('mouseenter', handleMouseInCanvas); reactFlowWrapper?.current?.removeEventListener('mouseenter', handleMouseInCanvas);
reactFlowWrapper?.current?.removeEventListener('mouseleave', handleMouseOutCanvas); reactFlowWrapper?.current?.removeEventListener('mouseleave', handleMouseOutCanvas);
}; };
}, [reactFlowWrapper?.current, setMouseInCanvas]); }, [setMouseInCanvas]);
// Watch hover node // Watch hover node
const [hoverNodeId, setHoverNodeId] = useState<string>(); const [hoverNodeId, setHoverNodeId] = useState<string>();
@@ -95,7 +109,9 @@ const WorkflowEventContextProvider = ({ children }: { children: ReactNode }) =>
menu, menu,
setMenu, setMenu,
showHistoryModal, showHistoryModal,
setShowHistoryModal setShowHistoryModal,
handleParams,
setHandleParams
}), }),
[ [
mouseInCanvas, mouseInCanvas,
@@ -108,7 +124,9 @@ const WorkflowEventContextProvider = ({ children }: { children: ReactNode }) =>
menu, menu,
setMenu, setMenu,
showHistoryModal, showHistoryModal,
setShowHistoryModal setShowHistoryModal,
handleParams,
setHandleParams
] ]
); );
return ( return (

View File

@@ -2,7 +2,7 @@ import { computedNodeInputReference } from '@/web/core/workflow/utils';
import { type AppDetailType } from '@fastgpt/global/core/app/type'; import { type AppDetailType } from '@fastgpt/global/core/app/type';
import { NodeInputKeyEnum, NodeOutputKeyEnum } from '@fastgpt/global/core/workflow/constants'; import { NodeInputKeyEnum, 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 { type StoreEdgeItemType } from '@fastgpt/global/core/workflow/type/edge'; import type { StoreEdgeItemType } from '@fastgpt/global/core/workflow/type/edge';
import { import {
type FlowNodeItemType, type FlowNodeItemType,
type StoreNodeItemType type StoreNodeItemType

View File

@@ -170,11 +170,16 @@ export const storeNode2FlowNode = ({
zIndex zIndex
}; };
}; };
export const storeEdgesRenderEdge = ({ edge }: { edge: StoreEdgeItemType }) => { export const storeEdge2RenderEdge = ({ edge }: { edge: StoreEdgeItemType }) => {
const sourceHandle = edge.sourceHandle.replace(/-source-(top|bottom|left)$/, '-source-right');
const targetHandle = edge.targetHandle.replace(/-target-(top|bottom|right)$/, '-target-left');
return { return {
...edge, ...edge,
id: getNanoid(), id: getNanoid(),
type: EDGE_TYPE type: EDGE_TYPE,
sourceHandle,
targetHandle
}; };
}; };