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`参数,提高迭代搜索的数据总量。
2. 开放飞书和语雀知识库到开源版
3. gemini 和 claude 最新模型预设
2. 工作流调整为单向接入和接出,支持快速的添加下一步节点
3. 开放飞书和语雀知识库到开源版
4. gemini 和 claude 最新模型预设。
## ⚙️ 优化

View File

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

View File

@@ -1,8 +1,10 @@
{
"Array_element": "Array element",
"Array_element_index": "Index",
"Click": "Click ",
"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.",
"Drag": "Drag ",
"Node.Open_Node_Course": "Open node course",
"Node_variables": "Node variables",
"Quote_prompt_setting": "Quote prompt",
@@ -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 operation results",
"to_add_node": "to add",
"to_connect_node": "to connect",
"tool_call_termination": "Stop ToolCall",
"tool_custom_field": "Custom Tool",
"tool_field": " Tool Field Parameter Configuration",

View File

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

View File

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