Feature/export import stage 2 (#3063)

* add export all function

* modify exportAll to reuse existing code from other services

* modify routes of export-import

* add exportAll function into UI

* add errorhandler

* add importAll Function into UI for ChatFlow

* modify importAll Function to import tools

* remove appServer variable

* modify exportAll to exportData for new requirement in backend

* chore modify type camelCase to PascalCase in exportImportService

* add import export for variables, assistants, and checkboxes for UI

---------

Co-authored-by: Henry <hzj94@hotmail.com>
This commit is contained in:
Ong Chung Yau
2024-09-11 01:15:45 +08:00
committed by GitHub
parent 40a1064a8f
commit 56f9208d7c
18 changed files with 646 additions and 91 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
import client from './client'
const getAllChatflows = () => client.get('/chatflows')
const getAllChatflows = () => client.get('/chatflows?type=CHATFLOW')
const getAllAgentflows = () => client.get('/chatflows?type=MULTIAGENT')
+9
View File
@@ -0,0 +1,9 @@
import client from './client'
const exportData = (body) => client.post('/export-import/export', body)
const importData = (body) => client.post('/export-import/import', body)
export default {
exportData,
importData
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 799 KiB

@@ -1,9 +1,11 @@
import { closeSnackbar as closeSnackbarAction, enqueueSnackbar as enqueueSnackbarAction, MENU_OPEN, REMOVE_DIRTY } from '@/store/actions'
import { sanitizeChatflows } from '@/utils/genericHelper'
import { closeSnackbar as closeSnackbarAction, enqueueSnackbar as enqueueSnackbarAction, REMOVE_DIRTY } from '@/store/actions'
import { exportData, stringify } from '@/utils/exportImport'
import useNotifier from '@/utils/useNotifier'
import PropTypes from 'prop-types'
import { useEffect, useRef, useState } from 'react'
import { useDispatch, useSelector } from 'react-redux'
import { createPortal } from 'react-dom'
// material-ui
import {
Avatar,
@@ -18,7 +20,14 @@ import {
ListItemText,
Paper,
Popper,
Typography
Typography,
Dialog,
DialogTitle,
DialogContent,
Stack,
FormControlLabel,
Checkbox,
DialogActions
} from '@mui/material'
import { useTheme } from '@mui/material/styles'
@@ -33,13 +42,114 @@ import Transitions from '@/ui-component/extended/Transitions'
// assets
import { IconFileExport, IconFileUpload, IconInfoCircle, IconLogout, IconSettings, IconX } from '@tabler/icons-react'
import './index.css'
import ExportingGIF from '@/assets/images/Exporting.gif'
//API
import chatFlowsApi from '@/api/chatflows'
import exportImportApi from '@/api/exportimport'
// Hooks
import useApi from '@/hooks/useApi'
import { useLocation, useNavigate } from 'react-router-dom'
import { getErrorMessage } from '@/utils/errorHandler'
import { useNavigate } from 'react-router-dom'
const dataToExport = ['Chatflows', 'Agentflows', 'Tools', 'Variables', 'Assistants']
const ExportDialog = ({ show, onCancel, onExport }) => {
const portalElement = document.getElementById('portal')
const [selectedData, setSelectedData] = useState(['Chatflows', 'Agentflows', 'Tools', 'Variables', 'Assistants'])
const [isExporting, setIsExporting] = useState(false)
useEffect(() => {
if (show) setIsExporting(false)
return () => {
setIsExporting(false)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [show])
const component = show ? (
<Dialog
onClose={!isExporting ? onCancel : undefined}
open={show}
fullWidth
maxWidth='sm'
aria-labelledby='export-dialog-title'
aria-describedby='export-dialog-description'
>
<DialogTitle sx={{ fontSize: '1rem' }} id='export-dialog-title'>
{!isExporting ? 'Select Data to Export' : 'Exporting..'}
</DialogTitle>
<DialogContent>
{!isExporting && (
<Stack direction='row' sx={{ gap: 1, flexWrap: 'wrap' }}>
{dataToExport.map((data, index) => (
<FormControlLabel
key={index}
size='small'
control={
<Checkbox
color='success'
checked={selectedData.includes(data)}
onChange={(event) => {
setSelectedData(
event.target.checked
? [...selectedData, data]
: selectedData.filter((item) => item !== data)
)
}}
/>
}
label={data}
/>
))}
</Stack>
)}
{isExporting && (
<Box sx={{ height: 'auto', display: 'flex', justifyContent: 'center', mb: 3 }}>
<div style={{ display: 'flex', flexDirection: 'column' }}>
<img
style={{
objectFit: 'cover',
height: 'auto',
width: 'auto'
}}
src={ExportingGIF}
alt='ExportingGIF'
/>
<span>Exporting data might takes a while</span>
</div>
</Box>
)}
</DialogContent>
{!isExporting && (
<DialogActions>
<Button onClick={onCancel}>Cancel</Button>
<Button
disabled={selectedData.length === 0}
variant='contained'
onClick={() => {
setIsExporting(true)
onExport(selectedData)
}}
>
Export
</Button>
</DialogActions>
)}
</Dialog>
) : null
return createPortal(component, portalElement)
}
ExportDialog.propTypes = {
show: PropTypes.bool,
onCancel: PropTypes.func,
onExport: PropTypes.func
}
// ==============================|| PROFILE MENU ||============================== //
@@ -50,12 +160,16 @@ const ProfileSection = ({ username, handleLogout }) => {
const [open, setOpen] = useState(false)
const [aboutDialogOpen, setAboutDialogOpen] = useState(false)
const [exportDialogOpen, setExportDialogOpen] = useState(false)
const anchorRef = useRef(null)
const inputRef = useRef()
const navigate = useNavigate()
const location = useLocation()
const importAllApi = useApi(exportImportApi.importData)
const exportAllApi = useApi(exportImportApi.exportData)
const prevOpen = useRef(open)
// ==============================|| Snackbar ||============================== //
@@ -90,7 +204,7 @@ const ProfileSection = ({ username, handleLogout }) => {
}
})
}
const importChatflowsApi = useApi(chatFlowsApi.importChatflows)
const fileChange = (e) => {
if (!e.target.files) return
@@ -101,16 +215,16 @@ const ProfileSection = ({ username, handleLogout }) => {
if (!evt?.target?.result) {
return
}
const chatflows = JSON.parse(evt.target.result)
importChatflowsApi.request(chatflows)
const body = JSON.parse(evt.target.result)
importAllApi.request(body)
}
reader.readAsText(file)
}
const importChatflowsSuccess = () => {
const importAllSuccess = () => {
dispatch({ type: REMOVE_DIRTY })
enqueueSnackbar({
message: `Import chatflows successful`,
message: `Import All successful`,
options: {
key: new Date().getTime() + Math.random(),
variant: 'success',
@@ -122,60 +236,75 @@ const ProfileSection = ({ username, handleLogout }) => {
}
})
}
useEffect(() => {
if (importChatflowsApi.error) errorFailed(`Failed to import chatflows: ${importChatflowsApi.error.response.data.message}`)
if (importChatflowsApi.data) {
importChatflowsSuccess()
// if current location is /chatflows, refresh the page
if (location.pathname === '/chatflows') navigate(0)
else {
// if not redirect to /chatflows
dispatch({ type: MENU_OPEN, id: 'chatflows' })
navigate('/chatflows')
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [importChatflowsApi.error, importChatflowsApi.data])
const importAllChatflows = () => {
const importAll = () => {
inputRef.current.click()
}
const getAllChatflowsApi = useApi(chatFlowsApi.getAllChatflows)
const exportChatflowsSuccess = () => {
dispatch({ type: REMOVE_DIRTY })
enqueueSnackbar({
message: `Export chatflows successful`,
options: {
key: new Date().getTime() + Math.random(),
variant: 'success',
action: (key) => (
<Button style={{ color: 'white' }} onClick={() => closeSnackbar(key)}>
<IconX />
</Button>
)
}
})
const onExport = (data) => {
const body = {}
if (data.includes('Chatflows')) body.chatflow = true
if (data.includes('Agentflows')) body.agentflow = true
if (data.includes('Tools')) body.tool = true
if (data.includes('Variables')) body.variable = true
if (data.includes('Assistants')) body.assistant = true
exportAllApi.request(body)
}
useEffect(() => {
if (getAllChatflowsApi.error) errorFailed(`Failed to export Chatflows: ${getAllChatflowsApi.error.response.data.message}`)
if (getAllChatflowsApi.data) {
const sanitizedChatflows = sanitizeChatflows(getAllChatflowsApi.data)
const dataStr = JSON.stringify({ Chatflows: sanitizedChatflows }, null, 2)
const dataUri = 'data:application/json;charset=utf-8,' + encodeURIComponent(dataStr)
const exportFileDefaultName = 'AllChatflows.json'
const linkElement = document.createElement('a')
linkElement.setAttribute('href', dataUri)
linkElement.setAttribute('download', exportFileDefaultName)
linkElement.click()
exportChatflowsSuccess()
if (importAllApi.data) {
importAllSuccess()
navigate(0)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [getAllChatflowsApi.error, getAllChatflowsApi.data])
}, [importAllApi.data])
useEffect(() => {
if (importAllApi.error) {
let errMsg = 'Invalid Imported File'
let error = importAllApi.error
if (error?.response?.data) {
errMsg = typeof error.response.data === 'object' ? error.response.data.message : error.response.data
}
errorFailed(`Failed to import: ${errMsg}`)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [importAllApi.error])
useEffect(() => {
if (exportAllApi.data) {
setExportDialogOpen(false)
try {
const dataStr = stringify(exportData(exportAllApi.data))
//const dataUri = 'data:application/json;charset=utf-8,' + encodeURIComponent(dataStr)
const blob = new Blob([dataStr], { type: 'application/json' })
const dataUri = URL.createObjectURL(blob)
const linkElement = document.createElement('a')
linkElement.setAttribute('href', dataUri)
linkElement.setAttribute('download', exportAllApi.data.FileDefaultName)
linkElement.click()
} catch (error) {
errorFailed(`Failed to export all: ${getErrorMessage(error)}`)
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [exportAllApi.data])
useEffect(() => {
if (exportAllApi.error) {
setExportDialogOpen(false)
let errMsg = 'Internal Server Error'
let error = exportAllApi.error
if (error?.response?.data) {
errMsg = typeof error.response.data === 'object' ? error.response.data.message : error.response.data
}
errorFailed(`Failed to export: ${errMsg}`)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [exportAllApi.error])
const prevOpen = useRef(open)
useEffect(() => {
if (prevOpen.current === true && open === false) {
anchorRef.current.focus()
@@ -258,26 +387,26 @@ const ProfileSection = ({ username, handleLogout }) => {
<ListItemButton
sx={{ borderRadius: `${customization.borderRadius}px` }}
onClick={() => {
getAllChatflowsApi.request()
setExportDialogOpen(true)
}}
>
<ListItemIcon>
<IconFileExport stroke={1.5} size='1.3rem' />
</ListItemIcon>
<ListItemText primary={<Typography variant='body2'>Export Chatflows</Typography>} />
<ListItemText primary={<Typography variant='body2'>Export</Typography>} />
</ListItemButton>
<ListItemButton
sx={{ borderRadius: `${customization.borderRadius}px` }}
onClick={() => {
importAllChatflows()
importAll()
}}
>
<ListItemIcon>
<IconFileUpload stroke={1.5} size='1.3rem' />
</ListItemIcon>
<ListItemText primary={<Typography variant='body2'>Import Chatflows</Typography>} />
<ListItemText primary={<Typography variant='body2'>Import</Typography>} />
</ListItemButton>
<input ref={inputRef} type='file' hidden onChange={fileChange} />
<input ref={inputRef} type='file' hidden onChange={fileChange} accept='.json' />
<ListItemButton
sx={{ borderRadius: `${customization.borderRadius}px` }}
onClick={() => {
@@ -311,6 +440,7 @@ const ProfileSection = ({ username, handleLogout }) => {
)}
</Popper>
<AboutDialog show={aboutDialogOpen} onCancel={() => setAboutDialogOpen(false)} />
<ExportDialog show={exportDialogOpen} onCancel={() => setExportDialogOpen(false)} onExport={(data) => onExport(data)} />
</>
)
}
+19
View File
@@ -0,0 +1,19 @@
const isErrorWithMessage = (error) => {
return typeof error === 'object' && error !== null && 'message' in error && typeof error.message === 'string'
}
const toErrorWithMessage = (maybeError) => {
if (isErrorWithMessage(maybeError)) return maybeError
try {
return new Error(JSON.stringify(maybeError))
} catch {
// fallback in case there's an error stringifying the maybeError
// like with circular references for example.
return new Error(String(maybeError))
}
}
export const getErrorMessage = (error) => {
return toErrorWithMessage(error).message
}
+88
View File
@@ -0,0 +1,88 @@
import { getErrorMessage } from './errorHandler'
import { generateExportFlowData } from './genericHelper'
const sanitizeTool = (Tool) => {
try {
return Tool.map((tool) => {
return {
id: tool.id,
name: tool.name,
description: tool.description,
color: tool.color,
iconSrc: tool.iconSrc,
schema: tool.schema,
func: tool.func
}
})
} catch (error) {
throw new Error(`exportImport.sanitizeTool ${getErrorMessage(error)}`)
}
}
const sanitizeChatflow = (ChatFlow) => {
try {
return ChatFlow.map((chatFlow) => {
const sanitizeFlowData = generateExportFlowData(JSON.parse(chatFlow.flowData))
return {
id: chatFlow.id,
name: chatFlow.name,
flowData: stringify(sanitizeFlowData),
type: chatFlow.type
}
})
} catch (error) {
throw new Error(`exportImport.sanitizeChatflow ${getErrorMessage(error)}`)
}
}
const sanitizeVariable = (Variable) => {
try {
return Variable.map((variable) => {
return {
id: variable.id,
name: variable.name,
value: variable.value,
type: variable.type
}
})
} catch (error) {
throw new Error(`exportImport.sanitizeVariable ${getErrorMessage(error)}`)
}
}
const sanitizeAssistant = (Assistant) => {
try {
return Assistant.map((assistant) => {
return {
id: assistant.id,
details: assistant.details,
credential: assistant.credential,
iconSrc: assistant.iconSrc
}
})
} catch (error) {
throw new Error(`exportImport.sanitizeAssistant ${getErrorMessage(error)}`)
}
}
export const stringify = (object) => {
try {
return JSON.stringify(object, null, 2)
} catch (error) {
throw new Error(`exportImport.stringify ${getErrorMessage(error)}`)
}
}
export const exportData = (exportAllData) => {
try {
return {
Tool: sanitizeTool(exportAllData.Tool),
ChatFlow: sanitizeChatflow(exportAllData.ChatFlow),
AgentFlow: sanitizeChatflow(exportAllData.AgentFlow),
Variable: sanitizeVariable(exportAllData.Variable),
Assistant: sanitizeAssistant(exportAllData.Assistant)
}
} catch (error) {
throw new Error(`exportImport.exportData ${getErrorMessage(error)}`)
}
}
+1 -13
View File
@@ -1,5 +1,5 @@
import moment from 'moment'
import { uniq } from 'lodash'
import moment from 'moment'
export const getUniqueNodeId = (nodeData, nodes) => {
// Get amount of same nodes
@@ -373,18 +373,6 @@ export const getFolderName = (base64ArrayStr) => {
}
}
export const sanitizeChatflows = (arrayChatflows) => {
const sanitizedChatflows = arrayChatflows.map((chatFlow) => {
const sanitizeFlowData = generateExportFlowData(JSON.parse(chatFlow.flowData))
return {
id: chatFlow.id,
name: chatFlow.name,
flowData: JSON.stringify(sanitizeFlowData, null, 2)
}
})
return sanitizedChatflows
}
export const generateExportFlowData = (flowData) => {
const nodes = flowData.nodes
const edges = flowData.edges
@@ -165,7 +165,8 @@ const AssistantDialog = ({ show, dialogProps, onCancel, onConfirm, setError }) =
useEffect(() => {
if (getAssistantObjApi.error) {
let errMsg = ''
let errMsg = 'Internal Server Error'
let error = getAssistantObjApi.error
if (error?.response?.data) {
errMsg = typeof error.response.data === 'object' ? error.response.data.message : error.response.data
}