diff --git a/docSite/content/zh-cn/docs/development/upgrading/4910.md b/docSite/content/zh-cn/docs/development/upgrading/4910.md index 92163f14e..adba6d854 100644 --- a/docSite/content/zh-cn/docs/development/upgrading/4910.md +++ b/docSite/content/zh-cn/docs/development/upgrading/4910.md @@ -11,8 +11,9 @@ weight: 790 ## 🚀 新增内容 1. 支持 PG 设置`systemEnv.hnswMaxScanTuples`参数,提高迭代搜索的数据总量。 -2. 开放飞书和语雀知识库到开源版。 -3. gemini 和 claude 最新模型预设。 +2. 工作流调整为单向接入和接出,支持快速的添加下一步节点。 +3. 开放飞书和语雀知识库到开源版。 +4. gemini 和 claude 最新模型预设。 ## ⚙️ 优化 diff --git a/packages/web/components/common/Tabs/FillRowTabs.tsx b/packages/web/components/common/Tabs/FillRowTabs.tsx index 198e59cee..da1376b11 100644 --- a/packages/web/components/common/Tabs/FillRowTabs.tsx +++ b/packages/web/components/common/Tabs/FillRowTabs.tsx @@ -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 = Omit & { @@ -10,9 +10,22 @@ type Props = Omit & { }[]; 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 ( {list.map((item) => ( - onChange(item.value) })} > - {item.icon && } - {item.label} - + {item.icon && } + {item.label} + ))} ); diff --git a/packages/web/i18n/en/workflow.json b/packages/web/i18n/en/workflow.json index 8764a2660..af40b3bf1 100644 --- a/packages/web/i18n/en/workflow.json +++ b/packages/web/i18n/en/workflow.json @@ -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", diff --git a/packages/web/i18n/zh-CN/workflow.json b/packages/web/i18n/zh-CN/workflow.json index 831e3665c..7a358bcdb 100644 --- a/packages/web/i18n/zh-CN/workflow.json +++ b/packages/web/i18n/zh-CN/workflow.json @@ -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": "自定义工具变量", diff --git a/packages/web/i18n/zh-Hant/workflow.json b/packages/web/i18n/zh-Hant/workflow.json index 35fd4409f..5a746e55e 100644 --- a/packages/web/i18n/zh-Hant/workflow.json +++ b/packages/web/i18n/zh-Hant/workflow.json @@ -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": "工具參數設定", diff --git a/projects/app/src/pageComponents/app/detail/SimpleApp/Header.tsx b/projects/app/src/pageComponents/app/detail/SimpleApp/Header.tsx index 45c55be3a..26d0c436f 100644 --- a/projects/app/src/pageComponents/app/detail/SimpleApp/Header.tsx +++ b/projects/app/src/pageComponents/app/detail/SimpleApp/Header.tsx @@ -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 }); diff --git a/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/NodeTemplatesModal.tsx b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/NodeTemplatesModal.tsx index 813585a34..24398948e 100644 --- a/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/NodeTemplatesModal.tsx +++ b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/NodeTemplatesModal.tsx @@ -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(''); - 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((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[] }) => { + 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'} /> { userSelect={'none'} overflow={isOpen ? 'none' : 'hidden'} > - - @@ -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 ( - - {/* Tabs */} - - - { - loadNodeTemplates({ - type: e as TemplateTypeEnum, - parentId: '' - }); - }} - /> - - {/* close icon */} - } - bg={'myGray.100'} - _hover={{ - bg: 'myGray.200', - '& svg': { - color: 'primary.600' - } - }} - variant={'grayBase'} - aria-label={''} - onClick={onClose} - /> - - {/* Search */} - {(templateType === TemplateTypeEnum.teamPlugin || - templateType === TemplateTypeEnum.systemPlugin) && ( - - - - - - setSearchKey(e.target.value)} - /> - - - {templateType === TemplateTypeEnum.teamPlugin && ( - router.push('/dashboard/apps')} - gap={1} - > - {t('common:create')} - - - )} - {templateType === TemplateTypeEnum.systemPlugin && feConfigs.systemPluginCourseUrl && ( - window.open(feConfigs.systemPluginCourseUrl)} - gap={1} - > - {t('common:plugin.contribute')} - - - )} - - )} - {/* paths */} - {(templateType === TemplateTypeEnum.teamPlugin || - templateType === TemplateTypeEnum.systemPlugin) && - !searchKey && - parentId && ( - - - - )} - - ); -}); - -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 = { - [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 ( - - - {t(item.label as any)} - - - {item.list.map((template) => { - return ( - - - - - {t(template.name as any)} - - - - {t(template.intro as any) || t('common:core.workflow.Not intro')} - - {type === TemplateTypeEnum.systemPlugin && ( - - )} - - } - > - { - 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'} - > - - - {t(template.name as any)} - - - {template.isFolder && templateType === TemplateTypeEnum.teamPlugin && ( - { - e.stopPropagation(); - return setParentId(template.id); - }} - > - - - )} - - {gridStyle.authorInRight && template.authorAvatar && template.author && ( - - - - {template.author} - - - )} - - - ); - })} - - - ); - })} - - ); - }); - - return templates.length === 0 ? ( - - ) : ( - 1 ? 2 : 5}> - - {formatTemplatesArray.length > 1 ? ( - <> - {formatTemplatesArray.map(({ list, label }, index) => ( - - - {t(label as any)} - - - - - - - ))} - - ) : ( - - )} - - - ); -}); diff --git a/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/NodeTemplatesPopover.tsx b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/NodeTemplatesPopover.tsx new file mode 100644 index 000000000..bde73ff0c --- /dev/null +++ b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/NodeTemplatesPopover.tsx @@ -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[] }) => { + 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 ( + setHandleParams(null)} + closeOnBlur={true} + closeOnEsc={true} + autoFocus={true} + isLazy + > + + + + + + + + + + ); +}; + +export default React.memo(NodeTemplatesPopover); diff --git a/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/components/ButtonEdge.tsx b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/components/ButtonEdge.tsx index 62962cd20..e210e0942 100644 --- a/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/components/ButtonEdge.tsx +++ b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/components/ButtonEdge.tsx @@ -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 ( + } + shouldWrapChildren={false} + > + { + 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 }); + }} + > + + + {t(template.name as any)} + + + {/* Folder right arrow */} + {template.isFolder && templateType === TemplateTypeEnum.teamPlugin && ( + { + e.stopPropagation(); + onUpdateParentId(template.id); + }} + > + + + )} + + {/* Author */} + {!isPopover && template.authorAvatar && template.author && ( + + + + {template.author} + + + )} + + + ); +}; + +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 = { + [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 ( + + + {t(item.label as any)} + + + {item.list.map((template) => ( + + ))} + + + ); + })} + + ); + }); + + return templates.length === 0 ? ( + + ) : ( + 1 ? 2 : 5}> + + {formatTemplatesArrayData.length > 1 ? ( + <> + {formatTemplatesArrayData.map(({ list, label }, index) => ( + + + {t(label as any)} + + + + + + + ))} + + ) : ( + + )} + + + ); +}; + +export default React.memo(NodeTemplateList); diff --git a/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/components/NodeTemplates/useNodeTemplates.tsx b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/components/NodeTemplates/useNodeTemplates.tsx new file mode 100644 index 000000000..fea38ba7e --- /dev/null +++ b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/components/NodeTemplates/useNodeTemplates.tsx @@ -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(''); + + 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((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 + }; +}; diff --git a/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/hooks/useWorkflow.tsx b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/hooks/useWorkflow.tsx index b60ae9c2b..486a644b1 100644 --- a/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/hooks/useWorkflow.tsx +++ b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/hooks/useWorkflow.tsx @@ -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]); diff --git a/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/index.tsx b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/index.tsx index f53407103..d205f8a53 100644 --- a/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/index.tsx +++ b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/index.tsx @@ -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 = { @@ -127,6 +128,7 @@ const Workflow = () => { }} /> + ) => { }); }} /> - {!snapshot.isDragging && ( - ) => { {IfElseResultEnum.ELSE} - {!snapshot.isDragging && ( - 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 ( - ); })(); - 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 ( - - ); - })(); - 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 ( - - ); - })(); - 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 ( - - ); - })(); 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 ( - ); })(); - const rightHandle = (() => { - if (!node || !node?.targetHandle?.right) return null; - - const handleId = getHandleId(nodeId, 'target', Position.Right); - - return ( - - ); - })(); - const topHandle = (() => { - if (!node || !node?.targetHandle?.top) return null; - - const handleId = getHandleId(nodeId, 'target', Position.Top); - - return ( - - ); - })(); - const bottomHandle = (() => { - if (!node || !node?.targetHandle?.bottom) return null; - - const handleId = getHandleId(nodeId, 'target', Position.Bottom); - - return ( - - ); - })(); return { showHandle, - LeftHandle, - rightHandle, - topHandle, - bottomHandle + LeftHandle }; }, [connectingEdge, edges, nodeId, nodeList]); - return ( - <> - {LeftHandle} - {rightHandle} - {topHandle} - {bottomHandle} - - ); + return <>{LeftHandle}; }); export default function Dom() { diff --git a/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/render/Handle/ToolHandle.tsx b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/render/Handle/ToolHandle.tsx index 490cfce53..41c52324b 100644 --- a/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/render/Handle/ToolHandle.tsx +++ b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/render/Handle/ToolHandle.tsx @@ -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) => { > { /> ); - }, [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 ( @@ -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} > { ); - }, [onConnect, t]); + }, [onConnect, size, t]); return Render; }; diff --git a/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/render/Handle/index.tsx b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/render/Handle/index.tsx index 9772fc808..2565baccf 100644 --- a/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/render/Handle/index.tsx +++ b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/render/Handle/index.tsx @@ -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; - connectedStyle: Record; -}) { + 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 ( + + + {t('workflow:Click')} + {t('workflow:to_add_node')} + + + {t('workflow:Drag')} + {t('workflow:to_connect_node')} + + + } + shouldWrapChildren={false} + > )} - ); - }, [handleId, position, showAddIcon, styles, transform]); - - if (!node) return null; - if (connectingEdge?.handleId === NodeOutputKeyEnum.selectedTools) return null; - - return <>{RenderHandle}; + + ); }); -export const SourceHandle = (props: Props) => { - return ( - - ); -}; - -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; - connectedStyle: Record; }) { - 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 ( - - ); - }, [styles, showHandle, transform, handleId, position]); - - return RenderHandle; -}); - -export const TargetHandle = ( - props: Props & { - showHandle: boolean; - } -) => { return ( - ); -}; +}); export default function Dom() { return <>; diff --git a/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/render/Handle/style.tsx b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/render/Handle/style.tsx deleted file mode 100644 index 21a9f0332..000000000 --- a/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/render/Handle/style.tsx +++ /dev/null @@ -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 <>; -} diff --git a/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/render/NodeCard.tsx b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/render/NodeCard.tsx index 70e7363af..5771011da 100644 --- a/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/render/NodeCard.tsx +++ b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/render/NodeCard.tsx @@ -308,8 +308,9 @@ const NodeCard = (props: Props) => { ); }, [nodeId, isFolded]); const RenderToolHandle = useMemo( - () => (node?.flowNodeType === FlowNodeTypeEnum.tools ? : null), - [node?.flowNodeType] + () => + node?.flowNodeType === FlowNodeTypeEnum.tools ? : null, + [node?.flowNodeType, nodeId] ); return ( diff --git a/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/render/RenderOutput/Label.tsx b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/render/RenderOutput/Label.tsx index c37d2d7df..cf7e8f19b 100644 --- a/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/render/RenderOutput/Label.tsx +++ b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/render/RenderOutput/Label.tsx @@ -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 )} {output.type === FlowNodeOutputTypeEnum.source && ( - { 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 = (() => { diff --git a/projects/app/src/pageComponents/app/detail/WorkflowComponents/context/workflowEventContext.tsx b/projects/app/src/pageComponents/app/detail/WorkflowComponents/context/workflowEventContext.tsx index 7e055b0ce..3c917a8eb 100644 --- a/projects/app/src/pageComponents/app/detail/WorkflowComponents/context/workflowEventContext.tsx +++ b/projects/app/src/pageComponents/app/detail/WorkflowComponents/context/workflowEventContext.tsx @@ -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>; + handleParams: handleParamsType | null; + setHandleParams: React.Dispatch>; }; export const WorkflowEventContext = createContext({ @@ -42,6 +50,10 @@ export const WorkflowEventContext = createContext({ showHistoryModal: false, setShowHistoryModal: function (value: React.SetStateAction): void { throw new Error('Function not implemented.'); + }, + handleParams: null, + setHandleParams: function (value: React.SetStateAction): void { + throw new Error('Function not implemented.'); } }); @@ -49,6 +61,8 @@ const WorkflowEventContextProvider = ({ children }: { children: ReactNode }) => // Watch mouse in canvas const reactFlowWrapper = useRef(null); const [mouseInCanvas, setMouseInCanvas] = useState(false); + const [handleParams, setHandleParams] = useState(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(); @@ -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 ( diff --git a/projects/app/src/pageComponents/app/detail/WorkflowComponents/utils.tsx b/projects/app/src/pageComponents/app/detail/WorkflowComponents/utils.tsx index a8e1b99f3..839b4e6d0 100644 --- a/projects/app/src/pageComponents/app/detail/WorkflowComponents/utils.tsx +++ b/projects/app/src/pageComponents/app/detail/WorkflowComponents/utils.tsx @@ -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 diff --git a/projects/app/src/web/core/workflow/utils.ts b/projects/app/src/web/core/workflow/utils.ts index 6848736ec..656a98321 100644 --- a/projects/app/src/web/core/workflow/utils.ts +++ b/projects/app/src/web/core/workflow/utils.ts @@ -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 }; };