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

@@ -32,7 +32,7 @@ import { isProduction } from '@fastgpt/global/common/system/constants';
import { useToast } from '@fastgpt/web/hooks/useToast';
import {
checkWorkflowNodeAndConnection,
storeEdgesRenderEdge,
storeEdge2RenderEdge,
storeNode2FlowNode
} from '@/web/core/workflow/utils';
@@ -246,7 +246,7 @@ const Header = ({
const { nodes: storeNodes, edges: storeEdges } = form2AppWorkflow(appForm, 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 });

View File

@@ -1,212 +1,46 @@
import React, { useCallback, useMemo, useState } from 'react';
import {
Accordion,
AccordionButton,
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 React from 'react';
import { Box } from '@chakra-ui/react';
import type { FlowNodeItemType } from '@fastgpt/global/core/workflow/type/node.d';
import { type Node } from 'reactflow';
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 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 CostTooltip from '@/components/core/app/plugin/CostTooltip';
import MyAvatar from '@fastgpt/web/components/common/Avatar';
import { useMemoizedFn } from 'ahooks';
import NodeTemplateListHeader from './components/NodeTemplates/header';
import NodeTemplateList from './components/NodeTemplates/list';
import { useNodeTemplates } from './components/NodeTemplates/useNodeTemplates';
type ModuleTemplateListProps = {
isOpen: boolean;
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 {
'basic' = 'basic',
'systemPlugin' = 'systemPlugin',
'teamPlugin' = 'teamPlugin'
}
const sliderWidth = 460;
export const sliderWidth = 460;
const NodeTemplatesModal = ({ isOpen, onClose }: ModuleTemplateListProps) => {
const [parentId, setParentId] = useState<ParentIdType>('');
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 setNodes = useContextSelector(WorkflowNodeEdgeContext, (v) => v.setNodes);
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 {
data: teamAndSystemApps,
loading: isLoading,
runAsync: loadNodeTemplates
} = useRequest2(
async ({
parentId = '',
type = templateType,
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]
}
);
templateType,
parentId,
templatesIsLoading,
templates,
loadNodeTemplates,
onUpdateParentId
} = useNodeTemplates();
const templates = useMemo(
() => basicNodes || teamAndSystemApps || [],
[basicNodes, teamAndSystemApps]
);
const onUpdateParentId = useCallback(
(parentId: ParentIdType) => {
loadNodeTemplates({
parentId
});
},
[loadNodeTemplates]
);
// Init load refresh templates
useRequest2(
() =>
loadNodeTemplates({
parentId: '',
searchVal: searchKey
}),
{
manual: false,
throttleWait: 300,
refreshDeps: [searchKey]
}
);
const onAddNode = useMemoizedFn(async ({ newNodes }: { newNodes: Node<FlowNodeItemType>[] }) => {
setNodes((state) => {
const newState = state
.map((node) => ({
...node,
selected: false
}))
// @ts-ignore
.concat(newNodes);
return newState;
});
});
return (
<>
@@ -223,7 +57,7 @@ const NodeTemplatesModal = ({ isOpen, onClose }: ModuleTemplateListProps) => {
fontSize={'sm'}
/>
<MyBox
isLoading={isLoading}
isLoading={templatesIsLoading}
display={'flex'}
zIndex={3}
flexDirection={'column'}
@@ -241,22 +75,18 @@ const NodeTemplatesModal = ({ isOpen, onClose }: ModuleTemplateListProps) => {
userSelect={'none'}
overflow={isOpen ? 'none' : 'hidden'}
>
<RenderHeader
templateType={templateType}
<NodeTemplateListHeader
onClose={onClose}
parentId={parentId}
onUpdateParentId={onUpdateParentId}
searchKey={searchKey}
templateType={templateType}
loadNodeTemplates={loadNodeTemplates}
setSearchKey={setSearchKey}
parentId={parentId || ''}
onUpdateParentId={onUpdateParentId}
/>
<RenderList
templateType={templateType}
<NodeTemplateList
onAddNode={onAddNode}
templates={templates}
type={templateType}
onClose={onClose}
parentId={parentId}
setParentId={onUpdateParentId}
templateType={templateType}
onUpdateParentId={onUpdateParentId}
/>
</MyBox>
</>
@@ -264,531 +94,3 @@ const NodeTemplatesModal = ({ isOpen, onClose }: ModuleTemplateListProps) => {
};
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
};
}
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 {
newTargetX: targetX,
newTargetY: targetY
@@ -150,6 +132,7 @@ const ButtonEdge = (props: EdgeProps) => {
return `translate(-50%, -90%) translate(${newTargetX}px,${newTargetY}px) rotate(90deg)`;
}
})();
return (
<EdgeLabelRenderer>
<Box hidden={parentNode?.isFolded}>
@@ -160,8 +143,8 @@ const ButtonEdge = (props: EdgeProps) => {
position={'absolute'}
transform={`translate(-55%, -50%) translate(${labelX}px,${labelY}px)`}
pointerEvents={'all'}
w={'18px'}
h={'18px'}
w={'26px'}
h={'26px'}
bg={'white'}
borderRadius={'18px'}
cursor={'pointer'}
@@ -177,8 +160,8 @@ const ButtonEdge = (props: EdgeProps) => {
position={'absolute'}
transform={arrowTransform}
pointerEvents={'all'}
w={highlightEdge ? '12px' : '10px'}
h={highlightEdge ? '12px' : '10px'}
w={highlightEdge ? '14px' : '12px'}
h={highlightEdge ? '14px' : '12px'}
zIndex={highlightEdge ? defaultZIndex + 1000 : defaultZIndex}
>
<MyIcon
@@ -197,8 +180,8 @@ const ButtonEdge = (props: EdgeProps) => {
highlightEdge,
labelX,
labelY,
isToolEdge,
defaultZIndex,
isToolEdge,
edgeColor,
targetPosition,
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 = () => {
const { toast } = useToast();
const { t } = useTranslation();
@@ -289,8 +292,9 @@ export const useWorkflow = () => {
WorkflowStatusContext,
(v) => v.resetParentNodeSizeAndPosition
);
const setHandleParams = useContextSelector(WorkflowEventContext, (v) => v.setHandleParams);
const { getIntersectingNodes } = useReactFlow();
const { getIntersectingNodes, flowToScreenPosition, getZoom } = useReactFlow();
const { isDowningCtrl } = useKeyboard();
/* 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 */
// Remove change node and its child nodes and edges
const handleRemoveNode = useMemoizedFn((change: NodeRemoveChange, nodeId: string) => {
@@ -525,21 +584,61 @@ export const useWorkflow = () => {
/* connect */
const onConnectStart = useCallback(
(event: any, params: OnConnectStartParams) => {
if (!params.nodeId) return;
const { nodeId, handleId } = params;
if (!nodeId) return;
// 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) {
return onChangeNode({
nodeId: params.nodeId,
onChangeNode({
nodeId,
type: 'attr',
key: 'isFolded',
value: false
});
}
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(() => {
setConnectingEdge(undefined);
@@ -629,7 +728,6 @@ export const useWorkflow = () => {
},
[setMenu]
);
const onPaneClick = useCallback(() => {
setMenu(null);
}, [setMenu]);

View File

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

View File

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

View File

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

View File

@@ -10,7 +10,7 @@ import { useContextSelector } from 'use-context-selector';
import { WorkflowContext } from '../../../context';
import Container from '../../components/Container';
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 ListItem from './ListItem';
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}>
{IfElseResultEnum.ELSE}
</Box>
<SourceHandle
<MySourceHandle
nodeId={nodeId}
handleId={elseHandleId}
position={Position.Right}

View File

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

View File

@@ -1,6 +1,6 @@
import React, { useMemo } from 'react';
import { Position } from 'reactflow';
import { SourceHandle, TargetHandle } from '.';
import { MySourceHandle, MyTargetHandle } from '.';
import { getHandleId } from '@fastgpt/global/core/workflow/utils';
import { NodeOutputKeyEnum } from '@fastgpt/global/core/workflow/constants';
import { useContextSelector } from 'use-context-selector';
@@ -18,7 +18,7 @@ export const ConnectionSourceHandle = ({
const edges = useContextSelector(WorkflowNodeEdgeContext, (v) => v.edges);
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);
/* not node/not connecting node, hidden */
@@ -49,7 +49,7 @@ export const ConnectionSourceHandle = ({
return null;
return (
<SourceHandle
<MySourceHandle
nodeId={nodeId}
handleId={handleId}
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 {
showSourceHandle,
RightHandle,
LeftHandlee,
TopHandlee,
BottomHandlee
RightHandle
};
}, [connectingEdge, edges, nodeId, nodeList, isFoldNode]);
return showSourceHandle ? (
<>
{RightHandle}
{LeftHandlee}
{TopHandlee}
{BottomHandlee}
</>
) : null;
return showSourceHandle ? <>{RightHandle}</> : null;
};
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 { connectingEdge, nodeList } = useContextSelector(WorkflowContext, (ctx) => ctx);
const { LeftHandle, rightHandle, topHandle, bottomHandle } = useMemo(() => {
const { LeftHandle } = useMemo(() => {
let node: FlowNodeItemType | undefined = undefined,
connectingNode: FlowNodeItemType | undefined = undefined;
for (const item of nodeList) {
@@ -196,7 +130,7 @@ export const ConnectionTargetHandle = React.memo(function ConnectionTargetHandle
const handleId = getHandleId(nodeId, 'target', Position.Left);
return (
<TargetHandle
<MyTargetHandle
nodeId={nodeId}
handleId={handleId}
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 {
showHandle,
LeftHandle,
rightHandle,
topHandle,
bottomHandle
LeftHandle
};
}, [connectingEdge, edges, nodeId, nodeList]);
return (
<>
{LeftHandle}
{rightHandle}
{topHandle}
{bottomHandle}
</>
);
return <>{LeftHandle}</>;
});
export default function Dom() {

View File

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

View File

@@ -1,6 +1,5 @@
import React, { useMemo } from 'react';
import { Handle, Position } from 'reactflow';
import { handleHighLightStyle, sourceCommonStyle, handleConnectedStyle, handleSize } from './style';
import { NodeOutputKeyEnum } from '@fastgpt/global/core/workflow/constants';
import { useContextSelector } from 'use-context-selector';
import { WorkflowContext } from '../../../../context';
@@ -10,6 +9,37 @@ import {
WorkflowInitContext
} from '../../../../context/workflowInitContext';
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 = {
nodeId: string;
@@ -18,23 +48,21 @@ type Props = {
translate?: [number, number];
};
const MySourceHandle = React.memo(function MySourceHandle({
export const MySourceHandle = React.memo(function MySourceHandle({
nodeId,
translate,
handleId,
position,
highlightStyle,
connectedStyle
}: Props & {
highlightStyle: Record<string, any>;
connectedStyle: Record<string, any>;
}) {
position
}: Props) {
const { t } = useTranslation();
const edges = useContextSelector(WorkflowNodeEdgeContext, (v) => v.edges);
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 node = useMemo(() => nodes.find((node) => node.data.nodeId === nodeId), [nodes, nodeId]);
const connected = edges.some((edge) => edge.sourceHandle === handleId);
const nodeFolded = node?.data.isFolded && edges.some((edge) => edge.source === nodeId);
const nodeIsHover = hoverNodeId === nodeId;
@@ -46,32 +74,16 @@ const MySourceHandle = React.memo(function MySourceHandle({
const translateStr = useMemo(() => {
if (!translate) return '';
if (position === Position.Right) {
return `${active ? translate[0] + 2 : 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`;
return `${active ? translate[0] + 6 : translate[0]}px, -50%`;
}
}, [active, position, translate]);
const transform = useMemo(
() => (translateStr ? `translate(${translateStr})` : ''),
[translateStr]
);
const { styles, showAddIcon } = useMemo(() => {
if (active) {
return {
styles: {
...highlightStyle,
...(translateStr && {
transform
})
...handleHighLightStyle,
transform: `${translateStr ? `translate(${translateStr})` : ''}`
},
showAddIcon: true
};
@@ -80,33 +92,42 @@ const MySourceHandle = React.memo(function MySourceHandle({
if (connected || nodeFolded) {
return {
styles: {
...connectedStyle,
...(translateStr && {
transform
})
...handleConnectedStyle,
transform: `${translateStr ? `translate(${translateStr})` : ''}`
},
showAddIcon: false
};
}
return {
styles: undefined,
styles: {
visibility: 'hidden' as const
},
showAddIcon: false
};
}, [active, connected, nodeFolded, highlightStyle, translateStr, transform, connectedStyle]);
}, [active, connected, nodeFolded, translateStr]);
const RenderHandle = useMemo(() => {
return (
if (!node) return null;
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
style={
!!styles
? styles
: {
visibility: 'hidden',
transform,
...handleSize
}
}
style={styles}
type="source"
id={handleId}
position={position}
@@ -117,125 +138,73 @@ const MySourceHandle = React.memo(function MySourceHandle({
name={'edgeAdd'}
color={'primary.500'}
pointerEvents={'none'}
w={'14px'}
h={'14px'}
w={`${handleAddIconSize}px`}
h={`${handleAddIconSize}px`}
/>
)}
</Handle>
);
}, [handleId, position, showAddIcon, styles, transform]);
if (!node) return null;
if (connectingEdge?.handleId === NodeOutputKeyEnum.selectedTools) return null;
return <>{RenderHandle}</>;
</MyTooltip>
);
});
export const SourceHandle = (props: Props) => {
return (
<MySourceHandle
{...props}
highlightStyle={{ ...sourceCommonStyle, ...handleHighLightStyle }}
connectedStyle={{ ...sourceCommonStyle, ...handleConnectedStyle }}
/>
);
};
const MyTargetHandle = React.memo(function MyTargetHandle({
export const MyTargetHandle = React.memo(function MyTargetHandle({
nodeId,
handleId,
position,
translate,
highlightStyle,
connectedStyle,
showHandle
}: Props & {
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 connected = edges.some((edge) => edge.targetHandle === handleId);
const translateStr = useMemo(() => {
if (!translate) return '';
if (position === Position.Right) {
return `${connectingEdge ? translate[0] + 2 : translate[0]}px, -50%`;
}
if (position === Position.Left) {
return `${connectingEdge ? translate[0] - 2 : 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`;
return `${connectingEdge ? translate[0] - 6 : translate[0]}px, -50%`;
}
}, [connectingEdge, position, translate]);
const transform = useMemo(
() => (translateStr ? `translate(${translateStr})` : ''),
[translateStr]
);
const styles = useMemo(() => {
if (!connectingEdge && !connected) return;
if ((!connectingEdge && !connected) || !showHandle) {
return {
visibility: 'hidden' as const
};
}
if (connectingEdge) {
return {
...highlightStyle,
transform
...handleHighLightStyle,
transform: `${translateStr ? `translate(${translateStr})` : ''}`
};
}
if (connected) {
return {
...connectedStyle,
transform
...handleConnectedStyle,
transform: `${translateStr ? `translate(${translateStr})` : ''}`
};
}
return;
}, [connected, connectingEdge, connectedStyle, highlightStyle, transform]);
return {
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 (
<MyTargetHandle
{...props}
highlightStyle={{ ...sourceCommonStyle, ...handleHighLightStyle }}
connectedStyle={{ ...sourceCommonStyle, ...handleConnectedStyle }}
<Handle
style={styles}
isConnectableEnd={styles && showHandle}
isConnectableStart={false}
type="target"
id={handleId}
position={position}
/>
);
};
});
export default function Dom() {
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]);
const RenderToolHandle = useMemo(
() => (node?.flowNodeType === FlowNodeTypeEnum.tools ? <ToolSourceHandle /> : null),
[node?.flowNodeType]
() =>
node?.flowNodeType === FlowNodeTypeEnum.tools ? <ToolSourceHandle nodeId={nodeId} /> : null,
[node?.flowNodeType, nodeId]
);
return (

View File

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

View File

@@ -2,7 +2,7 @@ import { postWorkflowDebug } from '@/web/core/workflow/api';
import {
checkWorkflowNodeAndConnection,
compareSnapshot,
storeEdgesRenderEdge,
storeEdge2RenderEdge,
storeNode2FlowNode
} from '@/web/core/workflow/utils';
import { getErrText } from '@fastgpt/global/common/error/utils';
@@ -861,7 +861,7 @@ const WorkflowContextProvider = ({
});
const onSwitchCloudVersion = useMemoizedFn((appVersion: AppVersionSchemaType) => {
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;
resetSnapshot({
@@ -912,7 +912,7 @@ const WorkflowContextProvider = ({
isInit?: boolean
) => {
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 数据存到内存并删除
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 { useLocalStorageState } from 'ahooks';
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 = {
mouseInCanvas: boolean;
@@ -20,6 +26,8 @@ type WorkflowEventContextType = {
// version history
showHistoryModal: boolean;
setShowHistoryModal: React.Dispatch<React.SetStateAction<boolean>>;
handleParams: handleParamsType | null;
setHandleParams: React.Dispatch<React.SetStateAction<handleParamsType | null>>;
};
export const WorkflowEventContext = createContext<WorkflowEventContextType>({
@@ -42,6 +50,10 @@ export const WorkflowEventContext = createContext<WorkflowEventContextType>({
showHistoryModal: false,
setShowHistoryModal: function (value: React.SetStateAction<boolean>): void {
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
const reactFlowWrapper = useRef<HTMLDivElement>(null);
const [mouseInCanvas, setMouseInCanvas] = useState(false);
const [handleParams, setHandleParams] = useState<handleParamsType | null>(null);
useEffect(() => {
const handleMouseInCanvas = (e: MouseEvent) => {
setMouseInCanvas(true);
@@ -62,7 +76,7 @@ const WorkflowEventContextProvider = ({ children }: { children: ReactNode }) =>
reactFlowWrapper?.current?.removeEventListener('mouseenter', handleMouseInCanvas);
reactFlowWrapper?.current?.removeEventListener('mouseleave', handleMouseOutCanvas);
};
}, [reactFlowWrapper?.current, setMouseInCanvas]);
}, [setMouseInCanvas]);
// Watch hover node
const [hoverNodeId, setHoverNodeId] = useState<string>();
@@ -95,7 +109,9 @@ const WorkflowEventContextProvider = ({ children }: { children: ReactNode }) =>
menu,
setMenu,
showHistoryModal,
setShowHistoryModal
setShowHistoryModal,
handleParams,
setHandleParams
}),
[
mouseInCanvas,
@@ -108,7 +124,9 @@ const WorkflowEventContextProvider = ({ children }: { children: ReactNode }) =>
menu,
setMenu,
showHistoryModal,
setShowHistoryModal
setShowHistoryModal,
handleParams,
setHandleParams
]
);
return (

View File

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

View File

@@ -170,11 +170,16 @@ export const storeNode2FlowNode = ({
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 {
...edge,
id: getNanoid(),
type: EDGE_TYPE
type: EDGE_TYPE,
sourceHandle,
targetHandle
};
};