diff --git a/packages/global/core/workflow/constants.ts b/packages/global/core/workflow/constants.ts index 9e7534fad..f4d1b02b2 100644 --- a/packages/global/core/workflow/constants.ts +++ b/packages/global/core/workflow/constants.ts @@ -151,7 +151,11 @@ export enum NodeInputKeyEnum { loopEndInput = 'loopEndInput', // form input - userInputForms = 'userInputForms' + userInputForms = 'userInputForms', + + // comment + commentText = 'commentText', + commentSize = 'commentSize' } export enum NodeOutputKeyEnum { diff --git a/packages/global/core/workflow/node/constant.ts b/packages/global/core/workflow/node/constant.ts index e734d15a0..18f224e68 100644 --- a/packages/global/core/workflow/node/constant.ts +++ b/packages/global/core/workflow/node/constant.ts @@ -130,7 +130,8 @@ export enum FlowNodeTypeEnum { loop = 'loop', loopStart = 'loopStart', loopEnd = 'loopEnd', - formInput = 'formInput' + formInput = 'formInput', + comment = 'comment' } // node IO value type diff --git a/packages/global/core/workflow/template/system/comment.ts b/packages/global/core/workflow/template/system/comment.ts new file mode 100644 index 000000000..0031d0608 --- /dev/null +++ b/packages/global/core/workflow/template/system/comment.ts @@ -0,0 +1,40 @@ +import { FlowNodeTypeEnum } from '../../node/constant'; +import { FlowNodeTemplateType } from '../../type/node.d'; +import { + FlowNodeTemplateTypeEnum, + NodeInputKeyEnum, + WorkflowIOValueTypeEnum +} from '../../constants'; +import { getHandleConfig } from '../utils'; + +export const CommentNode: FlowNodeTemplateType = { + id: FlowNodeTypeEnum.comment, + templateType: FlowNodeTemplateTypeEnum.systemInput, + flowNodeType: FlowNodeTypeEnum.comment, + sourceHandle: getHandleConfig(false, false, false, false), + targetHandle: getHandleConfig(false, false, false, false), + avatar: '', + name: '', + intro: '', + version: '4811', + inputs: [ + { + key: NodeInputKeyEnum.commentText, + renderTypeList: [], + valueType: WorkflowIOValueTypeEnum.string, + label: '', + value: '' + }, + { + key: NodeInputKeyEnum.commentSize, + renderTypeList: [], + valueType: WorkflowIOValueTypeEnum.object, + label: '', + value: { + width: 240, + height: 140 + } + } + ], + outputs: [] +}; diff --git a/packages/service/core/workflow/dispatch/index.ts b/packages/service/core/workflow/dispatch/index.ts index b03a1e2fa..16a37a311 100644 --- a/packages/service/core/workflow/dispatch/index.ts +++ b/packages/service/core/workflow/dispatch/index.ts @@ -107,6 +107,7 @@ const callbackMap: Record = { [FlowNodeTypeEnum.pluginConfig]: () => Promise.resolve(), [FlowNodeTypeEnum.emptyNode]: () => Promise.resolve(), [FlowNodeTypeEnum.globalVariable]: () => Promise.resolve(), + [FlowNodeTypeEnum.comment]: () => Promise.resolve(), [FlowNodeTypeEnum.runApp]: dispatchAppRequest // abandoned }; diff --git a/packages/web/components/common/Icon/constants.ts b/packages/web/components/common/Icon/constants.ts index 5aebe7d2f..dbe302e5f 100644 --- a/packages/web/components/common/Icon/constants.ts +++ b/packages/web/components/common/Icon/constants.ts @@ -7,6 +7,7 @@ export const iconPaths = { closeSolid: () => import('./icons/closeSolid.svg'), collectionLight: () => import('./icons/collectionLight.svg'), collectionSolid: () => import('./icons/collectionSolid.svg'), + comment: () => import('./icons/comment.svg'), 'common/add2': () => import('./icons/common/add2.svg'), 'common/addCircleLight': () => import('./icons/common/addCircleLight.svg'), 'common/addLight': () => import('./icons/common/addLight.svg'), diff --git a/packages/web/components/common/Icon/icons/comment.svg b/packages/web/components/common/Icon/icons/comment.svg new file mode 100644 index 000000000..358b200d4 --- /dev/null +++ b/packages/web/components/common/Icon/icons/comment.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/packages/web/i18n/en/workflow.json b/packages/web/i18n/en/workflow.json index 4b270115b..cdceb6320 100644 --- a/packages/web/i18n/en/workflow.json +++ b/packages/web/i18n/en/workflow.json @@ -24,6 +24,7 @@ "contains": "Contains", "content_to_retrieve": "Content to Retrieve", "content_to_search": "Content to Search", + "context_menu.add_comment": "Add comment", "create_link_error": "Error creating link", "custom_feedback": "Custom Feedback", "custom_input": "Custom Input", @@ -38,6 +39,7 @@ "edit_input": "Edit Input", "edit_output": "Edit output", "end_with": "Ends With", + "enter_comment": "Enter comment", "error_info_returns_empty_on_success": "Error information of code execution, returns empty on success", "execute_a_simple_script_code_usually_for_complex_data_processing": "Execute a simple script code, usually for complex data processing.", "execute_different_branches_based_on_conditions": "Execute different branches based on conditions.", diff --git a/packages/web/i18n/zh/workflow.json b/packages/web/i18n/zh/workflow.json index 7f308ee93..c193a5bab 100644 --- a/packages/web/i18n/zh/workflow.json +++ b/packages/web/i18n/zh/workflow.json @@ -24,6 +24,8 @@ "contains": "包含", "content_to_retrieve": "需要检索的内容", "content_to_search": "需要检索的内容", + "contextMenu.addComment": "添加注释", + "context_menu.add_comment": "添加注释", "create_link_error": "创建链接异常", "custom_feedback": "自定义反馈", "custom_input": "自定义输入", @@ -38,6 +40,7 @@ "edit_input": "编辑输入", "edit_output": "编辑输出", "end_with": "结束为", + "enter_comment": "输入注释", "error_info_returns_empty_on_success": "代码运行错误信息,成功时返回空", "execute_a_simple_script_code_usually_for_complex_data_processing": "执行一段简单的脚本代码,通常用于进行复杂的数据处理。", "execute_different_branches_based_on_conditions": "根据一定的条件,执行不同的分支。", diff --git a/projects/app/src/pages/app/detail/components/WorkflowComponents/Flow/components/ContextMenu.tsx b/projects/app/src/pages/app/detail/components/WorkflowComponents/Flow/components/ContextMenu.tsx new file mode 100644 index 000000000..12a352c6a --- /dev/null +++ b/projects/app/src/pages/app/detail/components/WorkflowComponents/Flow/components/ContextMenu.tsx @@ -0,0 +1,83 @@ +import { Box, Flex } from '@chakra-ui/react'; +import React from 'react'; +import MyIcon from '@fastgpt/web/components/common/Icon'; +import { useTranslation } from 'react-i18next'; +import { nodeTemplate2FlowNode } from '@/web/core/workflow/utils'; +import { CommentNode } from '@fastgpt/global/core/workflow/template/system/comment'; +import { useContextSelector } from 'use-context-selector'; +import { WorkflowContext } from '../../context'; +import { useReactFlow } from 'reactflow'; + +type ContextMenuProps = { + top: number; + left: number; +}; + +const ContextMenu = ({ top, left }: ContextMenuProps) => { + const { t } = useTranslation(); + const setNodes = useContextSelector(WorkflowContext, (ctx) => ctx.setNodes); + const setMenu = useContextSelector(WorkflowContext, (ctx) => ctx.setMenu); + + const { screenToFlowPosition } = useReactFlow(); + const newNode = nodeTemplate2FlowNode({ + template: CommentNode, + position: screenToFlowPosition({ x: left, y: top }), + t + }); + + return ( + + + { + setMenu(null); + setNodes((state) => { + const newState = state + .map((node) => ({ + ...node, + selected: false + })) + // @ts-ignore + .concat(newNode); + return newState; + }); + }} + zIndex={1} + > + + + {t('workflow:context_menu.add_comment')} + + + + ); +}; + +export default React.memo(ContextMenu); diff --git a/projects/app/src/pages/app/detail/components/WorkflowComponents/Flow/hooks/useWorkflow.tsx b/projects/app/src/pages/app/detail/components/WorkflowComponents/Flow/hooks/useWorkflow.tsx index a5fe5c213..816a6fecd 100644 --- a/projects/app/src/pages/app/detail/components/WorkflowComponents/Flow/hooks/useWorkflow.tsx +++ b/projects/app/src/pages/app/detail/components/WorkflowComponents/Flow/hooks/useWorkflow.tsx @@ -282,7 +282,8 @@ export const useWorkflow = () => { setEdges, onChangeNode, onEdgesChange, - setHoverEdgeId + setHoverEdgeId, + setMenu } = useContextSelector(WorkflowContext, (v) => v); const { getIntersectingNodes } = useReactFlow(); @@ -599,7 +600,7 @@ export const useWorkflow = () => { connect }); }, - [onConnect, t, toast, nodes] + [onConnect, t, toast] ); /* edge */ @@ -613,6 +614,24 @@ export const useWorkflow = () => { setHoverEdgeId(undefined); }, [setHoverEdgeId]); + // context menu + const onPaneContextMenu = useCallback( + (e: any) => { + // Prevent native context menu from showing + e.preventDefault(); + + setMenu({ + top: e.clientY - 64, + left: e.clientX - 12 + }); + }, + [setMenu] + ); + + const onPaneClick = useCallback(() => { + setMenu(null); + }, [setMenu]); + return { handleNodesChange, handleEdgeChange, @@ -624,7 +643,9 @@ export const useWorkflow = () => { onEdgeMouseLeave, helperLineHorizontal, helperLineVertical, - onNodeDragStop + onNodeDragStop, + onPaneContextMenu, + onPaneClick }; }; diff --git a/projects/app/src/pages/app/detail/components/WorkflowComponents/Flow/index.tsx b/projects/app/src/pages/app/detail/components/WorkflowComponents/Flow/index.tsx index eed9beee4..7d01b4e19 100644 --- a/projects/app/src/pages/app/detail/components/WorkflowComponents/Flow/index.tsx +++ b/projects/app/src/pages/app/detail/components/WorkflowComponents/Flow/index.tsx @@ -17,6 +17,7 @@ import { WorkflowContext } from '../context'; import { useWorkflow } from './hooks/useWorkflow'; import HelperLines from './components/HelperLines'; import FlowController from './components/FlowController'; +import ContextMenu from './components/ContextMenu'; export const minZoom = 0.1; export const maxZoom = 1.5; @@ -57,14 +58,15 @@ const nodeTypes: Record = { [FlowNodeTypeEnum.loop]: dynamic(() => import('./nodes/Loop/NodeLoop')), [FlowNodeTypeEnum.loopStart]: dynamic(() => import('./nodes/Loop/NodeLoopStart')), [FlowNodeTypeEnum.loopEnd]: dynamic(() => import('./nodes/Loop/NodeLoopEnd')), - [FlowNodeTypeEnum.formInput]: dynamic(() => import('./nodes/NodeFormInput')) + [FlowNodeTypeEnum.formInput]: dynamic(() => import('./nodes/NodeFormInput')), + [FlowNodeTypeEnum.comment]: dynamic(() => import('./nodes/NodeComment')) }; const edgeTypes = { [EDGE_TYPE]: ButtonEdge }; const Workflow = () => { - const { nodes, edges, reactFlowWrapper, workflowControlMode } = useContextSelector( + const { nodes, edges, menu, reactFlowWrapper, workflowControlMode } = useContextSelector( WorkflowContext, (v) => v ); @@ -79,7 +81,9 @@ const Workflow = () => { onEdgeMouseLeave, helperLineHorizontal, helperLineVertical, - onNodeDragStop + onNodeDragStop, + onPaneContextMenu, + onPaneClick } = useWorkflow(); const { @@ -121,6 +125,7 @@ const Workflow = () => { + {menu && } { onEdgeMouseEnter={onEdgeMouseEnter} onEdgeMouseLeave={onEdgeMouseLeave} panOnScrollSpeed={2} + onPaneContextMenu={onPaneContextMenu} + onPaneClick={onPaneClick} {...(workflowControlMode === 'select' ? { selectionMode: SelectionMode.Full, diff --git a/projects/app/src/pages/app/detail/components/WorkflowComponents/Flow/nodes/NodeComment.tsx b/projects/app/src/pages/app/detail/components/WorkflowComponents/Flow/nodes/NodeComment.tsx new file mode 100644 index 000000000..594bf3103 --- /dev/null +++ b/projects/app/src/pages/app/detail/components/WorkflowComponents/Flow/nodes/NodeComment.tsx @@ -0,0 +1,128 @@ +import React, { useCallback, useMemo, useRef, useState } from 'react'; +import NodeCard from './render/NodeCard'; +import { NodeProps } from 'reactflow'; +import { FlowNodeItemType } from '@fastgpt/global/core/workflow/type/node'; +import { Box, Textarea } from '@chakra-ui/react'; +import { useContextSelector } from 'use-context-selector'; +import { WorkflowContext } from '../../context'; +import { NodeInputKeyEnum } from '@fastgpt/global/core/workflow/constants'; +import MyIcon from '@fastgpt/web/components/common/Icon'; +import { useTranslation } from 'react-i18next'; + +const NodeComment = ({ data, selected }: NodeProps) => { + const { nodeId, inputs } = data; + const { commentText, commentSize } = useMemo( + () => ({ + commentText: inputs.find((item) => item.key === NodeInputKeyEnum.commentText), + commentSize: inputs.find((item) => item.key === NodeInputKeyEnum.commentSize) + }), + [inputs] + ); + + const onChangeNode = useContextSelector(WorkflowContext, (ctx) => ctx.onChangeNode); + + const { t } = useTranslation(); + const [size, setSize] = useState<{ + width: number; + height: number; + }>(commentSize?.value); + + const initialY = useRef(0); + const initialX = useRef(0); + + const handleMouseDown = useCallback( + (e: React.MouseEvent) => { + initialY.current = e.clientY; + initialX.current = e.clientX; + + const handleMouseMove = (e: MouseEvent) => { + const deltaY = e.clientY - initialY.current; + const deltaX = e.clientX - initialX.current; + setSize((prevSize) => ({ + width: prevSize.width + deltaX < 240 ? 240 : prevSize.width + deltaX, + height: prevSize.height + deltaY < 140 ? 140 : prevSize.height + deltaY + })); + initialY.current = e.clientY; + initialX.current = e.clientX; + commentSize && + onChangeNode({ + nodeId: nodeId, + type: 'updateInput', + key: NodeInputKeyEnum.commentSize, + value: { + ...commentSize, + value: { + width: size.width + deltaX, + height: size.height + deltaY + } + } + }); + }; + + const handleMouseUp = () => { + document.removeEventListener('mousemove', handleMouseMove); + document.removeEventListener('mouseup', handleMouseUp); + }; + + document.addEventListener('mousemove', handleMouseMove); + document.addEventListener('mouseup', handleMouseUp); + }, + [commentSize, nodeId, onChangeNode, size.height, size.width] + ); + + return ( + + + + + +