import React, { useCallback, useEffect, useRef, useState } from 'react'; import ReactFlow, { Background, Controls, ReactFlowProvider, addEdge, useNodesState, useEdgesState, XYPosition, Connection, useViewport } from 'reactflow'; import { Box, Flex, IconButton, useTheme, useDisclosure } from '@chakra-ui/react'; import { SmallCloseIcon } from '@chakra-ui/icons'; import { edgeOptions, connectionLineStyle, FlowModuleTypeEnum, FlowInputItemTypeEnum, FlowValueTypeEnum } from '@/constants/flow'; import { appModule2FlowNode, appModule2FlowEdge } from '@/utils/adapt'; import { FlowModuleItemType, FlowModuleTemplateType, FlowOutputTargetItemType, type FlowModuleItemChangeProps } from '@/types/flow'; import { AppModuleItemType } from '@/types/app'; import { customAlphabet } from 'nanoid'; import { useRequest } from '@/hooks/useRequest'; import type { AppSchema } from '@/types/mongoSchema'; import { useUserStore } from '@/store/user'; import { useToast } from '@/hooks/useToast'; import { useTranslation } from 'next-i18next'; import dynamic from 'next/dynamic'; import MyIcon from '@/components/Icon'; import ButtonEdge from './components/modules/ButtonEdge'; import MyTooltip from '@/components/MyTooltip'; import TemplateList from './components/TemplateList'; import ChatTest, { type ChatTestComponentRef } from './components/ChatTest'; const NodeChat = dynamic(() => import('./components/Nodes/NodeChat'), { ssr: false }); const NodeKbSearch = dynamic(() => import('./components/Nodes/NodeKbSearch'), { ssr: false }); const NodeHistory = dynamic(() => import('./components/Nodes/NodeHistory'), { ssr: false }); const NodeTFSwitch = dynamic(() => import('./components/Nodes/NodeTFSwitch'), { ssr: false }); const NodeAnswer = dynamic(() => import('./components/Nodes/NodeAnswer'), { ssr: false }); const NodeQuestionInput = dynamic(() => import('./components/Nodes/NodeQuestionInput'), { ssr: false }); const NodeCQNode = dynamic(() => import('./components/Nodes/NodeCQNode'), { ssr: false }); const NodeVariable = dynamic(() => import('./components/Nodes/NodeVariable'), { ssr: false }); const NodeUserGuide = dynamic(() => import('./components/Nodes/NodeUserGuide'), { ssr: false }); const NodeExtract = dynamic(() => import('./components/Nodes/NodeExtract'), { ssr: false }); const NodeHttp = dynamic(() => import('./components/Nodes/NodeHttp'), { ssr: false }); import 'reactflow/dist/style.css'; import styles from './index.module.scss'; const nanoid = customAlphabet('abcdefghijklmnopqrstuvwxyz1234567890', 6); const nodeTypes = { [FlowModuleTypeEnum.userGuide]: NodeUserGuide, [FlowModuleTypeEnum.variable]: NodeVariable, [FlowModuleTypeEnum.questionInput]: NodeQuestionInput, [FlowModuleTypeEnum.historyNode]: NodeHistory, [FlowModuleTypeEnum.chatNode]: NodeChat, [FlowModuleTypeEnum.kbSearchNode]: NodeKbSearch, [FlowModuleTypeEnum.tfSwitchNode]: NodeTFSwitch, [FlowModuleTypeEnum.answerNode]: NodeAnswer, [FlowModuleTypeEnum.classifyQuestion]: NodeCQNode, [FlowModuleTypeEnum.contentExtract]: NodeExtract, [FlowModuleTypeEnum.httpRequest]: NodeHttp // [FlowModuleTypeEnum.empty]: EmptyModule }; const edgeTypes = { buttonedge: ButtonEdge }; type Props = { app: AppSchema; fullScreen: boolean; onFullScreen: (val: boolean) => void }; const AppEdit = ({ app, fullScreen, onFullScreen }: Props) => { const theme = useTheme(); const { toast } = useToast(); const { t } = useTranslation(); const reactFlowWrapper = useRef(null); const ChatTestRef = useRef(null); const { updateAppDetail } = useUserStore(); const { x, y, zoom } = useViewport(); const [nodes, setNodes, onNodesChange] = useNodesState([]); const [edges, setEdges, onEdgesChange] = useEdgesState([]); const { isOpen: isOpenTemplate, onOpen: onOpenTemplate, onClose: onCloseTemplate } = useDisclosure(); const [testModules, setTestModules] = useState(); const onFixView = useCallback(() => { const btn = document.querySelector('.react-flow__controls-fitview') as HTMLButtonElement; setTimeout(() => { btn && btn.click(); }, 100); }, []); const onDelNode = useCallback( (nodeId: string) => { setNodes((state) => state.filter((item) => item.id !== nodeId)); setEdges((state) => state.filter((edge) => edge.source !== nodeId && edge.target !== nodeId)); }, [setEdges, setNodes] ); const onDelEdge = useCallback( ({ moduleId, sourceHandle, targetHandle }: { moduleId: string; sourceHandle?: string; targetHandle?: string; }) => { if (!sourceHandle && !targetHandle) return; setEdges((state) => state.filter((edge) => { if (edge.source === moduleId && edge.sourceHandle === sourceHandle) return false; if (edge.target === moduleId && edge.targetHandle === targetHandle) return false; return true; }) ); }, [setEdges] ); const flow2AppModules = useCallback(() => { const modules: AppModuleItemType[] = nodes.map((item) => ({ moduleId: item.data.moduleId, flowType: item.data.flowType, position: item.position, inputs: item.data.inputs.map((item) => ({ ...item, connected: item.type !== FlowInputItemTypeEnum.target })), outputs: item.data.outputs.map((item) => ({ ...item, targets: [] as FlowOutputTargetItemType[] })) })); // update inputs and outputs modules.forEach((module) => { module.inputs.forEach((input) => { input.connected = input.connected || !!edges.find( (edge) => edge.target === module.moduleId && edge.targetHandle === input.key ); }); module.outputs.forEach((output) => { output.targets = edges .filter( (edge) => edge.source === module.moduleId && edge.sourceHandle === output.key && edge.targetHandle ) .map((edge) => ({ moduleId: edge.target, key: edge.targetHandle || '' })); }); }); return modules; }, [edges, nodes]); const onChangeNode = useCallback( ({ moduleId, key, type = 'inputs', value }: FlowModuleItemChangeProps) => { setNodes((nodes) => nodes.map((node) => { if (node.id !== moduleId) return node; if (type === 'inputs') { return { ...node, data: { ...node.data, inputs: node.data.inputs.map((item) => (item.key === key ? value : item)) } }; } if (type === 'addInput') { const input = node.data.inputs.find((input) => input.key === value.key); if (input) { toast({ status: 'warning', title: 'key 重复' }); return { ...node, data: { ...node.data, inputs: node.data.inputs } }; } return { ...node, data: { ...node.data, inputs: node.data.inputs.concat(value) } }; } if (type === 'delInput') { onDelEdge({ moduleId, targetHandle: key }); return { ...node, data: { ...node.data, inputs: node.data.inputs.filter((item) => item.key !== key) } }; } // del output connect const delOutputs = node.data.outputs.filter( (item) => !value.find((output: FlowOutputTargetItemType) => output.key === item.key) ); delOutputs.forEach((output) => { onDelEdge({ moduleId, sourceHandle: output.key }); }); return { ...node, data: { ...node.data, outputs: value } }; }) ); }, [setNodes] ); const onAddNode = useCallback( ({ template, position }: { template: FlowModuleTemplateType; position: XYPosition }) => { if (!reactFlowWrapper.current) return; const reactFlowBounds = reactFlowWrapper.current.getBoundingClientRect(); const mouseX = (position.x - reactFlowBounds.left - x) / zoom - 100; const mouseY = (position.y - reactFlowBounds.top - y) / zoom; setNodes((state) => state.concat( appModule2FlowNode({ item: { ...template, moduleId: nanoid(), position: { x: mouseX, y: mouseY } }, onChangeNode, onDelNode, onDelEdge }) ) ); }, [onDelEdge, onChangeNode, onDelNode, setNodes, x, y, zoom] ); const onDelConnect = useCallback( (id: string) => { setEdges((state) => state.filter((item) => item.id !== id)); }, [setEdges] ); const onConnect = useCallback( ({ connect }: { connect: Connection }) => { const source = nodes.find((node) => node.id === connect.source)?.data; const sourceType = (() => { if (source?.flowType === FlowModuleTypeEnum.classifyQuestion) { return FlowValueTypeEnum.boolean; } return source?.outputs.find((output) => output.key === connect.sourceHandle)?.valueType; })(); const targetType = nodes .find((node) => node.id === connect.target) ?.data?.inputs.find((input) => input.key === connect.targetHandle)?.valueType; if (!sourceType || !targetType) { return toast({ status: 'warning', title: t('app.Connection is invalid') }); } if ( sourceType !== FlowValueTypeEnum.any && targetType !== FlowValueTypeEnum.any && sourceType !== targetType ) { return toast({ status: 'warning', title: t('app.Connection type is different') }); } setEdges((state) => addEdge( { ...connect, type: 'buttonedge', animated: true, data: { onDelete: onDelConnect } }, state ) ); }, [onDelConnect, setEdges, nodes] ); const { mutate: onclickSave, isLoading } = useRequest({ mutationFn: () => { console.log(flow2AppModules(), '===='); return updateAppDetail(app._id, { modules: flow2AppModules() }); }, successToast: '保存配置成功', errorToast: '保存配置异常', onSuccess() { ChatTestRef.current?.resetChatTest(); } }); const initData = useCallback( (app: AppSchema) => { const edges = appModule2FlowEdge({ modules: app.modules, onDelete: onDelConnect }); setEdges(edges); setNodes( app.modules.map((item) => appModule2FlowNode({ item, onChangeNode, onDelNode, onDelEdge }) ) ); onFixView(); }, [onDelConnect, setEdges, setNodes, onFixView, onChangeNode, onDelNode, onDelEdge] ); useEffect(() => { initData(JSON.parse(JSON.stringify(app))); }, [app, initData]); return ( <> {/* header */} {fullScreen ? ( <> } borderRadius={'md'} borderColor={'myGray.300'} variant={'base'} aria-label={''} onClick={() => { onFullScreen(false); onFixView(); }} /> {app.name} ) : ( <> 应用编排 } borderRadius={'lg'} variant={'base'} aria-label={'fullScreenLight'} onClick={() => { onFullScreen(true); onFixView(); }} /> )} {testModules ? ( } variant={'base'} color={'myGray.600'} borderRadius={'lg'} aria-label={''} onClick={() => setTestModules(undefined)} /> ) : ( } borderRadius={'lg'} aria-label={'save'} variant={'base'} onClick={() => { setTestModules(flow2AppModules()); }} /> )} } borderRadius={'lg'} isLoading={isLoading} aria-label={'save'} onClick={onclickSave} /> { e.preventDefault(); return false; }} > {/* open module template */} } transform={isOpenTemplate ? '' : 'rotate(135deg)'} transition={'0.2s ease'} aria-label={''} zIndex={1} boxShadow={'2px 2px 6px #85b1ff'} onClick={() => { isOpenTemplate ? onCloseTemplate() : onOpenTemplate(); }} /> { connect.sourceHandle && connect.targetHandle && onConnect({ connect }); }} > setTestModules(undefined)} /> ); }; const Flow = (data: Props) => ( {!!data.app._id && } ); export default React.memo(Flow);