524 lines
18 KiB
JavaScript
524 lines
18 KiB
JavaScript
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'
|
|
|
|
// material-ui
|
|
import { Toolbar, Box, AppBar, Button } from '@mui/material'
|
|
import { useTheme } from '@mui/material/styles'
|
|
|
|
// project imports
|
|
import CanvasNode from './CanvasNode'
|
|
import ButtonEdge from './ButtonEdge'
|
|
import CanvasHeader from './CanvasHeader'
|
|
import AddNodes from './AddNodes'
|
|
import ConfirmDialog from 'ui-component/dialog/ConfirmDialog'
|
|
import { ChatMessage } from 'views/chatmessage/ChatMessage'
|
|
import { flowContext } from 'store/context/ReactFlowContext'
|
|
|
|
// API
|
|
import nodesApi from 'api/nodes'
|
|
import chatflowsApi from 'api/chatflows'
|
|
|
|
// Hooks
|
|
import useApi from 'hooks/useApi'
|
|
import useConfirm from 'hooks/useConfirm'
|
|
|
|
// icons
|
|
import { IconX } from '@tabler/icons'
|
|
|
|
// utils
|
|
import { getUniqueNodeId, initNode, getEdgeLabelName, rearrangeToolsOrdering } from 'utils/genericHelper'
|
|
import useNotifier from 'utils/useNotifier'
|
|
|
|
const nodeTypes = { customNode: CanvasNode }
|
|
const edgeTypes = { buttonedge: ButtonEdge }
|
|
|
|
// ==============================|| CANVAS ||============================== //
|
|
|
|
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)
|
|
|
|
// ==============================|| Snackbar ||============================== //
|
|
|
|
useNotifier()
|
|
const enqueueSnackbar = (...args) => dispatch(enqueueSnackbarAction(...args))
|
|
const closeSnackbar = (...args) => dispatch(closeSnackbarAction(...args))
|
|
|
|
// ==============================|| ReactFlow ||============================== //
|
|
|
|
const [nodes, setNodes, onNodesChange] = useNodesState()
|
|
const [edges, setEdges, onEdgesChange] = useEdgesState()
|
|
|
|
const [selectedNode, setSelectedNode] = useState(null)
|
|
|
|
const reactFlowWrapper = useRef(null)
|
|
|
|
// ==============================|| Chatflow API ||============================== //
|
|
|
|
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)
|
|
|
|
// ==============================|| Events & Actions ||============================== //
|
|
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
|
|
// eslint-disable-next-line
|
|
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')
|
|
|
|
// check if the dropped element is valid
|
|
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)
|
|
},
|
|
|
|
// eslint-disable-next-line
|
|
[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 ||============================== //
|
|
|
|
// Get specific chatflow successful
|
|
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}`)
|
|
}
|
|
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [getSpecificChatflowApi.data, getSpecificChatflowApi.error])
|
|
|
|
// Create new chatflow successful
|
|
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}`)
|
|
}
|
|
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [createNewChatflowApi.data, createNewChatflowApi.error])
|
|
|
|
// Update chatflow successful
|
|
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}`)
|
|
}
|
|
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [updateChatflowApi.data, updateChatflowApi.error])
|
|
|
|
// Test chatflow failed
|
|
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>
|
|
)
|
|
}
|
|
})
|
|
}
|
|
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [testChatflowApi.error])
|
|
|
|
useEffect(() => setChatflow(canvasDataStore.chatflow), [canvasDataStore.chatflow])
|
|
|
|
// Initialization
|
|
useEffect(() => {
|
|
if (chatflowId) {
|
|
getSpecificChatflowApi.request(chatflowId)
|
|
} else {
|
|
setNodes([])
|
|
setEdges([])
|
|
dispatch({
|
|
type: SET_CHATFLOW,
|
|
chatflow: {
|
|
name: 'Untitled chatflow'
|
|
}
|
|
})
|
|
}
|
|
|
|
getNodesApi.request()
|
|
|
|
// Clear dirty state before leaving and remove any ongoing test triggers and webhooks
|
|
return () => {
|
|
setTimeout(() => dispatch({ type: REMOVE_DIRTY }), 0)
|
|
}
|
|
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
setCanvasDataStore(canvas)
|
|
}, [canvas])
|
|
|
|
useEffect(() => {
|
|
function handlePaste(e) {
|
|
const pasteData = e.clipboardData.getData('text')
|
|
//TODO: prevent paste event when input focused, temporary fix: catch chatflow syntax
|
|
if (pasteData.includes('{"nodes":[') && pasteData.includes('],"edges":[')) {
|
|
handleLoadFlow(pasteData)
|
|
}
|
|
}
|
|
|
|
window.addEventListener('paste', handlePaste)
|
|
|
|
return () => {
|
|
window.removeEventListener('paste', handlePaste)
|
|
}
|
|
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
if (templateFlowData && templateFlowData.includes('"nodes":[') && templateFlowData.includes('],"edges":[')) {
|
|
handleLoadFlow(templateFlowData)
|
|
}
|
|
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [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} />
|
|
<ChatMessage chatflowid={chatflowId} />
|
|
</ReactFlow>
|
|
</div>
|
|
</div>
|
|
</Box>
|
|
<ConfirmDialog />
|
|
</Box>
|
|
</>
|
|
)
|
|
}
|
|
|
|
export default Canvas
|