Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature: Autosave and Undo/Redo #3687

Open
wants to merge 30 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
a6d9062
Implement autosave and undo/redo for chatflow and agentflow
0xi4o Dec 12, 2024
718349d
Update saving indicator and show last saved timestamp
0xi4o Dec 12, 2024
1079c4e
Fix console error on first load
0xi4o Dec 12, 2024
9110187
Fix issues in undo/redo - intermediate steps, pre-existing history on…
0xi4o Dec 12, 2024
0188e14
Fix console errors in configuration and api code dialogs
0xi4o Dec 13, 2024
0bbe431
Trigger autosave when deleting nodes/edges and when duplicating nodes
0xi4o Dec 13, 2024
370a6fe
Track chatflow/agentflow name change in undo/redo history
0xi4o Dec 13, 2024
d0a9890
Update autosave indicator text
0xi4o Dec 13, 2024
63d67ea
Handle keyboard shortcuts for undo and redo
0xi4o Dec 13, 2024
e37ae82
Update node actions - remove tooltip and use css to hide/show
0xi4o Dec 14, 2024
9c75040
Prevent save indicator and autosaved showing for new chatflows before…
0xi4o Dec 16, 2024
4b3d50b
Fix onDragNodeStop triggering on clicking inside nodes and fix consol…
0xi4o Dec 17, 2024
43bb823
Trigger SET_DIRTY and SET_CHATFLOW when node inputs data changes
0xi4o Dec 17, 2024
e63f625
Fix issue where exiting canvas doesn't clear canvas data and history
0xi4o Dec 17, 2024
baaf56b
Fix issue with undo/redo keybindings not working
0xi4o Dec 17, 2024
4623387
Fix undo/redo not updating input fields in nodes
0xi4o Dec 17, 2024
6aa1ce0
Detect changes in additional params and format prompt values dialogs …
0xi4o Dec 19, 2024
7a0f3f4
Fix autosave on dropping nodes into the canvas
0xi4o Dec 20, 2024
0825738
Highlight condition dialog button if inputs inside condition dialog c…
0xi4o Dec 20, 2024
9c0a6e4
Enable redux devtools extension with tracing
0xi4o Dec 20, 2024
73c211a
Update reactflow controls styles in marketplace canvas
0xi4o Dec 20, 2024
8478285
Fix conflicts
0xi4o Dec 20, 2024
d1afba7
Update add nodes and sync versions buttons
0xi4o Dec 23, 2024
4368ca0
Optimize add nodes component
0xi4o Dec 23, 2024
f20d136
Avoid autosave on initial load
0xi4o Dec 23, 2024
6d06392
Go to agentflows page when pressing back in agent canvas
0xi4o Dec 23, 2024
1b43638
Memoize canvas node and sticky note components
0xi4o Dec 23, 2024
1ac0b0b
Fix conflicts
0xi4o Dec 23, 2024
8350552
Update styles for controls in flow and marketplace canvas
0xi4o Dec 23, 2024
edff7a8
Trigger autosave only on blur for input fields - string, password, nu…
0xi4o Dec 23, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion packages/ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,9 @@
"react-router": "~6.3.0",
"react-router-dom": "~6.3.0",
"react-syntax-highlighter": "^15.5.0",
"reactflow": "^11.5.6",
"reactflow": "^11.11.4",
"redux": "^4.0.5",
"redux-undo": "^1.1.0",
"rehype-mathjax": "^4.0.2",
"rehype-raw": "^7.0.0",
"remark-gfm": "^3.0.1",
Expand Down
98 changes: 98 additions & 0 deletions packages/ui/src/hooks/useAutoSave.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { useSelector } from 'react-redux'
import { isEqual } from 'lodash'

const useAutoSave = ({ onAutoSave, interval = 60000, debounce = 1000 }) => {
const canvas = useSelector((state) => state.canvas.present)
const [isSaving, setIsSaving] = useState(false)
const timeoutRef = useRef(null)
const previousSaveRef = useRef(null)
const onAutoSaveRef = useRef(onAutoSave)
const initialLoadRef = useRef(true)

useEffect(() => {
onAutoSaveRef.current = onAutoSave
}, [onAutoSave])

// debounced save function
const debouncedSave = useCallback(() => {
if (initialLoadRef.current) {
initialLoadRef.current = false
return
}

setIsSaving(true)

if (timeoutRef.current) {
clearTimeout(timeoutRef.current)
}

timeoutRef.current = setTimeout(() => {
if (canvas.chatflow && canvas.chatflow.flowData) {
const currentData = {
chatflowId: canvas.chatflow.id,
chatflowName: canvas.chatflow.name,
flowData: canvas.chatflow.flowData
}

const hasChanged = !previousSaveRef.current || !isEqual(currentData, previousSaveRef.current)

if (hasChanged) {
onAutoSaveRef.current(currentData)
previousSaveRef.current = currentData
}
}
setIsSaving(false)
}, debounce)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [debounce, canvas.chatflow?.id, canvas.chatflow?.name, canvas.chatflow?.flowData])

// watch for changes to trigger debounced save
useEffect(() => {
if (canvas.chatflow?.flowData || canvas.chatflow?.name) {
debouncedSave()
}
}, [canvas.chatflow?.flowData, canvas.chatflow?.name, debouncedSave])

// periodic saves
useEffect(() => {
const intervalId = setInterval(() => {
if (canvas.chatflow && !isEqual(canvas.chatflow, previousSaveRef.current)) {
onAutoSaveRef.current({
chatflowId: canvas.chatflow.id,
chatflowName: canvas.chatflow.name,
flowData: canvas.chatflow.flowData
})
previousSaveRef.current = canvas.chatflow
}
}, interval)

return () => {
clearInterval(intervalId)
if (timeoutRef.current) {
clearTimeout(timeoutRef.current)
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [interval])

// force an immediate save
const forceSave = useCallback(() => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current)
}

if (canvas.chatflow && !isEqual(canvas.chatflow, previousSaveRef.current)) {
onAutoSaveRef.current({
chatflowId: canvas.chatflow.id,
chatflowName: canvas.chatflow.name,
flowData: canvas.chatflow.flowData
})
previousSaveRef.current = canvas.chatflow
}
}, [canvas.chatflow])

return [canvas.chatflow, isSaving, forceSave]
}

export default useAutoSave
1 change: 1 addition & 0 deletions packages/ui/src/store/actions.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export const SHOW_CANVAS_DIALOG = '@canvas/SHOW_CANVAS_DIALOG'
export const HIDE_CANVAS_DIALOG = '@canvas/HIDE_CANVAS_DIALOG'
export const SET_COMPONENT_NODES = '@canvas/SET_COMPONENT_NODES'
export const SET_COMPONENT_CREDENTIALS = '@canvas/SET_COMPONENT_CREDENTIALS'
export const RESET_CANVAS = '@canvas/RESET_CANVAS'

// action - notifier reducer
export const ENQUEUE_SNACKBAR = 'ENQUEUE_SNACKBAR'
Expand Down
58 changes: 52 additions & 6 deletions packages/ui/src/store/context/ReactFlowContext.jsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import { createContext, useState } from 'react'
import { useDispatch } from 'react-redux'
import { useDispatch, useSelector } from 'react-redux'
import PropTypes from 'prop-types'
import { getUniqueNodeId } from '@/utils/genericHelper'
import { cloneDeep } from 'lodash'
import { SET_DIRTY } from '@/store/actions'
import { SET_CHATFLOW, SET_DIRTY } from '@/store/actions'

const initialValue = {
highlightedNodeId: '',
setHighlightedNodeId: () => {},
reactFlowInstance: null,
setReactFlowInstance: () => {},
duplicateNode: () => {},
Expand All @@ -16,20 +18,49 @@ const initialValue = {
export const flowContext = createContext(initialValue)

export const ReactFlowContext = ({ children }) => {
const canvas = useSelector((state) => state.canvas.present)
const dispatch = useDispatch()
const [highlightedNodeId, setHighlightedNodeId] = useState('')
const [reactFlowInstance, setReactFlowInstance] = useState(null)

const deleteNode = (nodeid) => {
deleteConnectedInput(nodeid, 'node')
reactFlowInstance.setNodes(reactFlowInstance.getNodes().filter((n) => n.id !== nodeid))
reactFlowInstance.setEdges(reactFlowInstance.getEdges().filter((ns) => ns.source !== nodeid && ns.target !== nodeid))
const updatedNodes = reactFlowInstance.getNodes().filter((n) => n.id !== nodeid)
const updatedEdges = reactFlowInstance.getEdges().filter((ns) => ns.source !== nodeid && ns.target !== nodeid)
reactFlowInstance.setNodes(updatedNodes)
reactFlowInstance.setEdges(updatedEdges)
dispatch({ type: SET_DIRTY })
const flowData = {
nodes: updatedNodes,
edges: updatedEdges,
viewport: reactFlowInstance?.getViewport()
}
dispatch({
type: SET_CHATFLOW,
chatflow: {
...canvas.chatflow,
flowData: JSON.stringify(flowData)
}
})
}

const deleteEdge = (edgeid) => {
deleteConnectedInput(edgeid, 'edge')
reactFlowInstance.setEdges(reactFlowInstance.getEdges().filter((edge) => edge.id !== edgeid))
const updatedEdges = reactFlowInstance.getEdges().filter((edge) => edge.id !== edgeid)
reactFlowInstance.setEdges(updatedEdges)
dispatch({ type: SET_DIRTY })
const flowData = {
nodes: reactFlowInstance.getNodes(),
edges: updatedEdges,
viewport: reactFlowInstance?.getViewport()
}
dispatch({
type: SET_CHATFLOW,
chatflow: {
...canvas.chatflow,
flowData: JSON.stringify(flowData)
}
})
}

const deleteConnectedInput = (id, type) => {
Expand Down Expand Up @@ -135,14 +166,29 @@ export const ReactFlowContext = ({ children }) => {
}
}

reactFlowInstance.setNodes([...nodes, duplicatedNode])
const updatedNodes = [...nodes, duplicatedNode]
reactFlowInstance.setNodes(updatedNodes)
dispatch({ type: SET_DIRTY })
const flowData = {
nodes: updatedNodes,
edges: reactFlowInstance.getEdges(),
viewport: reactFlowInstance?.getViewport()
}
dispatch({
type: SET_CHATFLOW,
chatflow: {
...canvas.chatflow,
flowData: JSON.stringify(flowData)
}
})
}
}

return (
<flowContext.Provider
value={{
highlightedNodeId,
setHighlightedNodeId,
reactFlowInstance,
setReactFlowInstance,
deleteNode,
Expand Down
2 changes: 1 addition & 1 deletion packages/ui/src/store/index.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import reducer from './reducer'

// ==============================|| REDUX - MAIN STORE ||============================== //

const store = createStore(reducer)
const store = createStore(reducer, window.__REDUX_DEVTOOLS_EXTENSION__ && window.__REDUX_DEVTOOLS_EXTENSION__({ trace: true }))
const persister = 'Free'

export { store, persister }
23 changes: 22 additions & 1 deletion packages/ui/src/store/reducers/canvasReducer.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
// action - state management
import undoable from 'redux-undo'
import * as actionTypes from '../actions'

export const initialState = {
Expand Down Expand Up @@ -48,9 +49,29 @@ const canvasReducer = (state = initialState, action) => {
...state,
componentCredentials: action.componentCredentials
}
case actionTypes.RESET_CANVAS:
return initialState
default:
return state
}
}

export default canvasReducer
const undoableCanvas = undoable(canvasReducer, {
debug: false, // set to true when debugging
filter: (action, currentState, previousHistory) => {
if (action.type !== actionTypes.SET_CHATFLOW) return false

const currentFlowData = currentState.chatflow?.flowData
const previousFlowData = previousHistory.present.chatflow?.flowData

const currentName = currentState.chatflow?.name
const previousName = previousHistory.present.chatflow?.name

return currentFlowData !== previousFlowData || currentName !== previousName
},
groupBy: (action) => (action.type === actionTypes.SET_CHATFLOW ? Math.floor(Date.now() / 2000) : null),
ignoreInitialState: true,
limit: 50
})

export default undoableCanvas
3 changes: 2 additions & 1 deletion packages/ui/src/ui-component/cards/NodeCardWrapper.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { memo } from 'react'
// material-ui
import { styled } from '@mui/material/styles'

Expand All @@ -18,4 +19,4 @@ const NodeCardWrapper = styled(MainCard)(({ theme }) => ({
}
}))

export default NodeCardWrapper
export default memo(NodeCardWrapper)
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import PerfectScrollbar from 'react-perfect-scrollbar'
import NodeInputHandler from '@/views/canvas/NodeInputHandler'
import { HIDE_CANVAS_DIALOG, SHOW_CANVAS_DIALOG } from '@/store/actions'

const AdditionalParamsDialog = ({ show, dialogProps, onCancel }) => {
const AdditionalParamsDialog = ({ show, dialogProps, onCancel, onNodeDataChange }) => {
const portalElement = document.getElementById('portal')
const dispatch = useDispatch()

Expand Down Expand Up @@ -54,6 +54,7 @@ const AdditionalParamsDialog = ({ show, dialogProps, onCancel }) => {
inputParam={inputParam}
data={data}
isAdditionalParams={true}
onNodeDataChange={onNodeDataChange}
/>
))}
</PerfectScrollbar>
Expand All @@ -67,7 +68,8 @@ const AdditionalParamsDialog = ({ show, dialogProps, onCancel }) => {
AdditionalParamsDialog.propTypes = {
show: PropTypes.bool,
dialogProps: PropTypes.object,
onCancel: PropTypes.func
onCancel: PropTypes.func,
onNodeDataChange: PropTypes.func
}

export default AdditionalParamsDialog
6 changes: 4 additions & 2 deletions packages/ui/src/ui-component/dialog/ConditionDialog.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import NodeInputHandler from '@/views/canvas/NodeInputHandler'
// Store
import { HIDE_CANVAS_DIALOG, SHOW_CANVAS_DIALOG } from '@/store/actions'

const ConditionDialog = ({ show, dialogProps, onCancel, onConfirm }) => {
const ConditionDialog = ({ show, dialogProps, onCancel, onConfirm, onNodeDataChange }) => {
const portalElement = document.getElementById('portal')

const dispatch = useDispatch()
Expand Down Expand Up @@ -66,6 +66,7 @@ const ConditionDialog = ({ show, dialogProps, onCancel, onConfirm }) => {
data={data}
isAdditionalParams={true}
disablePadding={true}
onNodeDataChange={onNodeDataChange}
/>
</TabPanel>
))}
Expand All @@ -89,7 +90,8 @@ ConditionDialog.propTypes = {
show: PropTypes.bool,
dialogProps: PropTypes.object,
onCancel: PropTypes.func,
onConfirm: PropTypes.func
onConfirm: PropTypes.func,
onNodeDataChange: PropTypes.func
}

export default ConditionDialog
1 change: 1 addition & 0 deletions packages/ui/src/ui-component/dropdown/AsyncDropdown.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,7 @@ export const AsyncDropdown = ({
)
}}
sx={{ height: '100%', '& .MuiInputBase-root': { height: '100%' } }}
key={`${nodeData.id}-${name}-${nodeData.inputs[internalValue]}`}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

there are other places that uses AsyncDropdown, for example when creating custom assistant. In those cases, there will be no nodeData, then it will throw error:

image

/>
)}
renderOption={(props, option) => (
Expand Down
10 changes: 9 additions & 1 deletion packages/ui/src/ui-component/editor/CodeEditor.jsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import PropTypes from 'prop-types'
import { useState } from 'react'
import CodeMirror from '@uiw/react-codemirror'
import { javascript } from '@codemirror/lang-javascript'
import { json } from '@codemirror/lang-json'
Expand All @@ -16,8 +17,10 @@ export const CodeEditor = ({
disabled = false,
autoFocus = false,
basicSetup = {},
onBlur,
onValueChange
}) => {
const [code, setCode] = useState(value)
const colorTheme = useTheme()

const customStyle = EditorView.baseTheme({
Expand Down Expand Up @@ -52,7 +55,11 @@ export const CodeEditor = ({
? [javascript({ jsx: true }), EditorView.lineWrapping, customStyle]
: [json(), EditorView.lineWrapping, customStyle]
}
onChange={onValueChange}
onBlur={() => onBlur?.(code)}
onChange={(val) => {
setCode(val)
onValueChange?.(val)
}}
readOnly={disabled}
editable={!disabled}
// eslint-disable-next-line
Expand All @@ -71,5 +78,6 @@ CodeEditor.propTypes = {
disabled: PropTypes.bool,
autoFocus: PropTypes.bool,
basicSetup: PropTypes.object,
onBlur: PropTypes.func,
onValueChange: PropTypes.func
}
2 changes: 1 addition & 1 deletion packages/ui/src/ui-component/extended/OverrideConfig.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ OverrideConfigTable.propTypes = {

const OverrideConfig = ({ dialogProps }) => {
const dispatch = useDispatch()
const chatflow = useSelector((state) => state.canvas.chatflow)
const chatflow = useSelector((state) => state.canvas.present.chatflow)
const chatflowid = chatflow.id
const apiConfig = chatflow.apiConfig ? JSON.parse(chatflow.apiConfig) : {}

Expand Down
2 changes: 1 addition & 1 deletion packages/ui/src/ui-component/extended/RateLimit.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import useNotifier from '@/utils/useNotifier'

const RateLimit = () => {
const dispatch = useDispatch()
const chatflow = useSelector((state) => state.canvas.chatflow)
const chatflow = useSelector((state) => state.canvas.present.chatflow)
const chatflowid = chatflow.id
const apiConfig = chatflow.apiConfig ? JSON.parse(chatflow.apiConfig) : {}

Expand Down
Loading
Loading