|
import { useEffect, useRef, useState, useCallback, useContext } from 'react' |
|
import ReactFlow, { addEdge, Controls, Background, useNodesState, useEdgesState } from 'reactflow' |
|
import 'reactflow/dist/style.css' |
|
|
|
import { useDispatch, useSelector } from 'react-redux' |
|
import { useNavigate, useLocation } from 'react-router-dom' |
|
import { usePrompt } from '../../utils/usePrompt' |
|
import { |
|
REMOVE_DIRTY, |
|
SET_DIRTY, |
|
SET_CHATFLOW, |
|
enqueueSnackbar as enqueueSnackbarAction, |
|
closeSnackbar as closeSnackbarAction |
|
} from 'store/actions' |
|
|
|
|
|
import { Toolbar, Box, AppBar, Button } from '@mui/material' |
|
import { useTheme } from '@mui/material/styles' |
|
|
|
|
|
import CanvasNode from './CanvasNode' |
|
import ButtonEdge from './ButtonEdge' |
|
import CanvasHeader from './CanvasHeader' |
|
import AddNodes from './AddNodes' |
|
import ConfirmDialog from 'ui-component/dialog/ConfirmDialog' |
|
import { ChatPopUp } from 'views/chatmessage/ChatPopUp' |
|
import { flowContext } from 'store/context/ReactFlowContext' |
|
|
|
|
|
import nodesApi from 'api/nodes' |
|
import chatflowsApi from 'api/chatflows' |
|
|
|
|
|
import useApi from 'hooks/useApi' |
|
import useConfirm from 'hooks/useConfirm' |
|
|
|
|
|
import { IconX } from '@tabler/icons' |
|
|
|
|
|
import { getUniqueNodeId, initNode, getEdgeLabelName, rearrangeToolsOrdering } from 'utils/genericHelper' |
|
import useNotifier from 'utils/useNotifier' |
|
|
|
const nodeTypes = { customNode: CanvasNode } |
|
const edgeTypes = { buttonedge: ButtonEdge } |
|
|
|
|
|
|
|
const Canvas = () => { |
|
const theme = useTheme() |
|
const navigate = useNavigate() |
|
|
|
const { state } = useLocation() |
|
const templateFlowData = state ? state.templateFlowData : '' |
|
|
|
const URLpath = document.location.pathname.toString().split('/') |
|
const chatflowId = URLpath[URLpath.length - 1] === 'canvas' ? '' : URLpath[URLpath.length - 1] |
|
|
|
const { confirm } = useConfirm() |
|
|
|
const dispatch = useDispatch() |
|
const canvas = useSelector((state) => state.canvas) |
|
const [canvasDataStore, setCanvasDataStore] = useState(canvas) |
|
const [chatflow, setChatflow] = useState(null) |
|
|
|
const { reactFlowInstance, setReactFlowInstance } = useContext(flowContext) |
|
|
|
|
|
|
|
useNotifier() |
|
const enqueueSnackbar = (...args) => dispatch(enqueueSnackbarAction(...args)) |
|
const closeSnackbar = (...args) => dispatch(closeSnackbarAction(...args)) |
|
|
|
|
|
|
|
const [nodes, setNodes, onNodesChange] = useNodesState() |
|
const [edges, setEdges, onEdgesChange] = useEdgesState() |
|
|
|
const [selectedNode, setSelectedNode] = useState(null) |
|
|
|
const reactFlowWrapper = useRef(null) |
|
|
|
|
|
|
|
const getNodesApi = useApi(nodesApi.getAllNodes) |
|
const createNewChatflowApi = useApi(chatflowsApi.createNewChatflow) |
|
const testChatflowApi = useApi(chatflowsApi.testChatflow) |
|
const updateChatflowApi = useApi(chatflowsApi.updateChatflow) |
|
const getSpecificChatflowApi = useApi(chatflowsApi.getSpecificChatflow) |
|
|
|
|
|
|
|
const onConnect = (params) => { |
|
const newEdge = { |
|
...params, |
|
type: 'buttonedge', |
|
id: `${params.source}-${params.sourceHandle}-${params.target}-${params.targetHandle}`, |
|
data: { label: getEdgeLabelName(params.sourceHandle) } |
|
} |
|
|
|
const targetNodeId = params.targetHandle.split('-')[0] |
|
const sourceNodeId = params.sourceHandle.split('-')[0] |
|
const targetInput = params.targetHandle.split('-')[2] |
|
|
|
setNodes((nds) => |
|
nds.map((node) => { |
|
if (node.id === targetNodeId) { |
|
setTimeout(() => setDirty(), 0) |
|
let value |
|
const inputAnchor = node.data.inputAnchors.find((ancr) => ancr.name === targetInput) |
|
const inputParam = node.data.inputParams.find((param) => param.name === targetInput) |
|
|
|
if (inputAnchor && inputAnchor.list) { |
|
const newValues = node.data.inputs[targetInput] || [] |
|
if (targetInput === 'tools') { |
|
rearrangeToolsOrdering(newValues, sourceNodeId) |
|
} else { |
|
newValues.push(`{{${sourceNodeId}.data.instance}}`) |
|
} |
|
value = newValues |
|
} else if (inputParam && inputParam.acceptVariable) { |
|
value = node.data.inputs[targetInput] || '' |
|
} else { |
|
value = `{{${sourceNodeId}.data.instance}}` |
|
} |
|
node.data = { |
|
...node.data, |
|
inputs: { |
|
...node.data.inputs, |
|
[targetInput]: value |
|
} |
|
} |
|
} |
|
return node |
|
}) |
|
) |
|
|
|
setEdges((eds) => addEdge(newEdge, eds)) |
|
} |
|
|
|
const handleLoadFlow = (file) => { |
|
try { |
|
const flowData = JSON.parse(file) |
|
const nodes = flowData.nodes || [] |
|
|
|
setNodes(nodes) |
|
setEdges(flowData.edges || []) |
|
setDirty() |
|
} catch (e) { |
|
console.error(e) |
|
} |
|
} |
|
|
|
const handleDeleteFlow = async () => { |
|
const confirmPayload = { |
|
title: `Delete`, |
|
description: `Delete chatflow ${chatflow.name}?`, |
|
confirmButtonName: 'Delete', |
|
cancelButtonName: 'Cancel' |
|
} |
|
const isConfirmed = await confirm(confirmPayload) |
|
|
|
if (isConfirmed) { |
|
try { |
|
await chatflowsApi.deleteChatflow(chatflow.id) |
|
navigate(-1) |
|
} catch (error) { |
|
const errorData = error.response.data || `${error.response.status}: ${error.response.statusText}` |
|
enqueueSnackbar({ |
|
message: errorData, |
|
options: { |
|
key: new Date().getTime() + Math.random(), |
|
variant: 'error', |
|
persist: true, |
|
action: (key) => ( |
|
<Button style={{ color: 'white' }} onClick={() => closeSnackbar(key)}> |
|
<IconX /> |
|
</Button> |
|
) |
|
} |
|
}) |
|
} |
|
} |
|
} |
|
|
|
const handleSaveFlow = (chatflowName) => { |
|
if (reactFlowInstance) { |
|
setNodes((nds) => |
|
nds.map((node) => { |
|
node.data = { |
|
...node.data, |
|
selected: false |
|
} |
|
return node |
|
}) |
|
) |
|
|
|
const rfInstanceObject = reactFlowInstance.toObject() |
|
const flowData = JSON.stringify(rfInstanceObject) |
|
|
|
if (!chatflow.id) { |
|
const newChatflowBody = { |
|
name: chatflowName, |
|
deployed: false, |
|
flowData |
|
} |
|
createNewChatflowApi.request(newChatflowBody) |
|
} else { |
|
const updateBody = { |
|
name: chatflowName, |
|
flowData |
|
} |
|
updateChatflowApi.request(chatflow.id, updateBody) |
|
} |
|
} |
|
} |
|
|
|
|
|
const onNodeClick = useCallback((event, clickedNode) => { |
|
setSelectedNode(clickedNode) |
|
setNodes((nds) => |
|
nds.map((node) => { |
|
if (node.id === clickedNode.id) { |
|
node.data = { |
|
...node.data, |
|
selected: true |
|
} |
|
} else { |
|
node.data = { |
|
...node.data, |
|
selected: false |
|
} |
|
} |
|
|
|
return node |
|
}) |
|
) |
|
}) |
|
|
|
const onDragOver = useCallback((event) => { |
|
event.preventDefault() |
|
event.dataTransfer.dropEffect = 'move' |
|
}, []) |
|
|
|
const onDrop = useCallback( |
|
(event) => { |
|
event.preventDefault() |
|
const reactFlowBounds = reactFlowWrapper.current.getBoundingClientRect() |
|
let nodeData = event.dataTransfer.getData('application/reactflow') |
|
|
|
|
|
if (typeof nodeData === 'undefined' || !nodeData) { |
|
return |
|
} |
|
|
|
nodeData = JSON.parse(nodeData) |
|
|
|
const position = reactFlowInstance.project({ |
|
x: event.clientX - reactFlowBounds.left - 100, |
|
y: event.clientY - reactFlowBounds.top - 50 |
|
}) |
|
|
|
const newNodeId = getUniqueNodeId(nodeData, reactFlowInstance.getNodes()) |
|
|
|
const newNode = { |
|
id: newNodeId, |
|
position, |
|
type: 'customNode', |
|
data: initNode(nodeData, newNodeId) |
|
} |
|
|
|
setSelectedNode(newNode) |
|
setNodes((nds) => |
|
nds.concat(newNode).map((node) => { |
|
if (node.id === newNode.id) { |
|
node.data = { |
|
...node.data, |
|
selected: true |
|
} |
|
} else { |
|
node.data = { |
|
...node.data, |
|
selected: false |
|
} |
|
} |
|
|
|
return node |
|
}) |
|
) |
|
setTimeout(() => setDirty(), 0) |
|
}, |
|
|
|
|
|
[reactFlowInstance] |
|
) |
|
|
|
const saveChatflowSuccess = () => { |
|
dispatch({ type: REMOVE_DIRTY }) |
|
enqueueSnackbar({ |
|
message: 'Chatflow saved', |
|
options: { |
|
key: new Date().getTime() + Math.random(), |
|
variant: 'success', |
|
action: (key) => ( |
|
<Button style={{ color: 'white' }} onClick={() => closeSnackbar(key)}> |
|
<IconX /> |
|
</Button> |
|
) |
|
} |
|
}) |
|
} |
|
|
|
const errorFailed = (message) => { |
|
enqueueSnackbar({ |
|
message, |
|
options: { |
|
key: new Date().getTime() + Math.random(), |
|
variant: 'error', |
|
persist: true, |
|
action: (key) => ( |
|
<Button style={{ color: 'white' }} onClick={() => closeSnackbar(key)}> |
|
<IconX /> |
|
</Button> |
|
) |
|
} |
|
}) |
|
} |
|
|
|
const setDirty = () => { |
|
dispatch({ type: SET_DIRTY }) |
|
} |
|
|
|
|
|
|
|
|
|
useEffect(() => { |
|
if (getSpecificChatflowApi.data) { |
|
const chatflow = getSpecificChatflowApi.data |
|
const initialFlow = chatflow.flowData ? JSON.parse(chatflow.flowData) : [] |
|
setNodes(initialFlow.nodes || []) |
|
setEdges(initialFlow.edges || []) |
|
dispatch({ type: SET_CHATFLOW, chatflow }) |
|
} else if (getSpecificChatflowApi.error) { |
|
const error = getSpecificChatflowApi.error |
|
const errorData = error.response.data || `${error.response.status}: ${error.response.statusText}` |
|
errorFailed(`Failed to retrieve chatflow: ${errorData}`) |
|
} |
|
|
|
|
|
}, [getSpecificChatflowApi.data, getSpecificChatflowApi.error]) |
|
|
|
|
|
useEffect(() => { |
|
if (createNewChatflowApi.data) { |
|
const chatflow = createNewChatflowApi.data |
|
dispatch({ type: SET_CHATFLOW, chatflow }) |
|
saveChatflowSuccess() |
|
window.history.replaceState(null, null, `/canvas/${chatflow.id}`) |
|
} else if (createNewChatflowApi.error) { |
|
const error = createNewChatflowApi.error |
|
const errorData = error.response.data || `${error.response.status}: ${error.response.statusText}` |
|
errorFailed(`Failed to save chatflow: ${errorData}`) |
|
} |
|
|
|
|
|
}, [createNewChatflowApi.data, createNewChatflowApi.error]) |
|
|
|
|
|
useEffect(() => { |
|
if (updateChatflowApi.data) { |
|
dispatch({ type: SET_CHATFLOW, chatflow: updateChatflowApi.data }) |
|
saveChatflowSuccess() |
|
} else if (updateChatflowApi.error) { |
|
const error = updateChatflowApi.error |
|
const errorData = error.response.data || `${error.response.status}: ${error.response.statusText}` |
|
errorFailed(`Failed to save chatflow: ${errorData}`) |
|
} |
|
|
|
|
|
}, [updateChatflowApi.data, updateChatflowApi.error]) |
|
|
|
|
|
useEffect(() => { |
|
if (testChatflowApi.error) { |
|
enqueueSnackbar({ |
|
message: 'Test chatflow failed', |
|
options: { |
|
key: new Date().getTime() + Math.random(), |
|
variant: 'error', |
|
persist: true, |
|
action: (key) => ( |
|
<Button style={{ color: 'white' }} onClick={() => closeSnackbar(key)}> |
|
<IconX /> |
|
</Button> |
|
) |
|
} |
|
}) |
|
} |
|
|
|
|
|
}, [testChatflowApi.error]) |
|
|
|
useEffect(() => setChatflow(canvasDataStore.chatflow), [canvasDataStore.chatflow]) |
|
|
|
|
|
useEffect(() => { |
|
if (chatflowId) { |
|
getSpecificChatflowApi.request(chatflowId) |
|
} else { |
|
if (localStorage.getItem('duplicatedFlowData')) { |
|
handleLoadFlow(localStorage.getItem('duplicatedFlowData')) |
|
setTimeout(() => localStorage.removeItem('duplicatedFlowData'), 0) |
|
} else { |
|
setNodes([]) |
|
setEdges([]) |
|
} |
|
dispatch({ |
|
type: SET_CHATFLOW, |
|
chatflow: { |
|
name: 'Untitled chatflow' |
|
} |
|
}) |
|
} |
|
|
|
getNodesApi.request() |
|
|
|
|
|
return () => { |
|
setTimeout(() => dispatch({ type: REMOVE_DIRTY }), 0) |
|
} |
|
|
|
|
|
}, []) |
|
|
|
useEffect(() => { |
|
setCanvasDataStore(canvas) |
|
}, [canvas]) |
|
|
|
useEffect(() => { |
|
function handlePaste(e) { |
|
const pasteData = e.clipboardData.getData('text') |
|
|
|
if (pasteData.includes('{"nodes":[') && pasteData.includes('],"edges":[')) { |
|
handleLoadFlow(pasteData) |
|
} |
|
} |
|
|
|
window.addEventListener('paste', handlePaste) |
|
|
|
return () => { |
|
window.removeEventListener('paste', handlePaste) |
|
} |
|
|
|
|
|
}, []) |
|
|
|
useEffect(() => { |
|
if (templateFlowData && templateFlowData.includes('"nodes":[') && templateFlowData.includes('],"edges":[')) { |
|
handleLoadFlow(templateFlowData) |
|
} |
|
|
|
|
|
}, [templateFlowData]) |
|
|
|
usePrompt('You have unsaved changes! Do you want to navigate away?', canvasDataStore.isDirty) |
|
|
|
return ( |
|
<> |
|
<Box> |
|
<AppBar |
|
enableColorOnDark |
|
position='fixed' |
|
color='inherit' |
|
elevation={1} |
|
sx={{ |
|
bgcolor: theme.palette.background.default |
|
}} |
|
> |
|
<Toolbar> |
|
<CanvasHeader |
|
chatflow={chatflow} |
|
handleSaveFlow={handleSaveFlow} |
|
handleDeleteFlow={handleDeleteFlow} |
|
handleLoadFlow={handleLoadFlow} |
|
/> |
|
</Toolbar> |
|
</AppBar> |
|
<Box sx={{ pt: '70px', height: '100vh', width: '100%' }}> |
|
<div className='reactflow-parent-wrapper'> |
|
<div className='reactflow-wrapper' ref={reactFlowWrapper}> |
|
<ReactFlow |
|
nodes={nodes} |
|
edges={edges} |
|
onNodesChange={onNodesChange} |
|
onNodeClick={onNodeClick} |
|
onEdgesChange={onEdgesChange} |
|
onDrop={onDrop} |
|
onDragOver={onDragOver} |
|
onNodeDragStop={setDirty} |
|
nodeTypes={nodeTypes} |
|
edgeTypes={edgeTypes} |
|
onConnect={onConnect} |
|
onInit={setReactFlowInstance} |
|
fitView |
|
minZoom={0.1} |
|
> |
|
<Controls |
|
style={{ |
|
display: 'flex', |
|
flexDirection: 'row', |
|
left: '50%', |
|
transform: 'translate(-50%, -50%)' |
|
}} |
|
/> |
|
<Background color='#aaa' gap={16} /> |
|
<AddNodes nodesData={getNodesApi.data} node={selectedNode} /> |
|
<ChatPopUp chatflowid={chatflowId} /> |
|
</ReactFlow> |
|
</div> |
|
</div> |
|
</Box> |
|
<ConfirmDialog /> |
|
</Box> |
|
</> |
|
) |
|
} |
|
|
|
export default Canvas |
|
|