### What problem does this PR solve? feat(agent): Added history management and paste handling features #3221 - Added a PasteHandlerPlugin to handle paste operations, optimizing the multi-line text pasting experience - Implemented the AgentHistoryManager class to manage history, supporting undo and redo functionality - Integrates history management functionality into the Agent component ### Type of change - [x] Bug Fix (non-breaking change which fixes an issue)tags/v0.20.1
| import { Variable } from 'lucide-react'; | import { Variable } from 'lucide-react'; | ||||
| import { ReactNode, useCallback, useState } from 'react'; | import { ReactNode, useCallback, useState } from 'react'; | ||||
| import { useTranslation } from 'react-i18next'; | import { useTranslation } from 'react-i18next'; | ||||
| import { PasteHandlerPlugin } from './paste-handler-plugin'; | |||||
| import theme from './theme'; | import theme from './theme'; | ||||
| import { VariableNode } from './variable-node'; | import { VariableNode } from './variable-node'; | ||||
| import { VariableOnChangePlugin } from './variable-on-change-plugin'; | import { VariableOnChangePlugin } from './variable-on-change-plugin'; | ||||
| ErrorBoundary={LexicalErrorBoundary} | ErrorBoundary={LexicalErrorBoundary} | ||||
| /> | /> | ||||
| <VariablePickerMenuPlugin value={value}></VariablePickerMenuPlugin> | <VariablePickerMenuPlugin value={value}></VariablePickerMenuPlugin> | ||||
| <PasteHandlerPlugin /> | |||||
| <VariableOnChangePlugin | <VariableOnChangePlugin | ||||
| onChange={onValueChange} | onChange={onValueChange} | ||||
| ></VariableOnChangePlugin> | ></VariableOnChangePlugin> |
| import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'; | |||||
| import { | |||||
| $createParagraphNode, | |||||
| $createTextNode, | |||||
| $getSelection, | |||||
| $isRangeSelection, | |||||
| PASTE_COMMAND, | |||||
| } from 'lexical'; | |||||
| import { useEffect } from 'react'; | |||||
| function PasteHandlerPlugin() { | |||||
| const [editor] = useLexicalComposerContext(); | |||||
| useEffect(() => { | |||||
| const removeListener = editor.registerCommand( | |||||
| PASTE_COMMAND, | |||||
| (clipboardEvent: ClipboardEvent) => { | |||||
| const clipboardData = clipboardEvent.clipboardData; | |||||
| if (!clipboardData) { | |||||
| return false; | |||||
| } | |||||
| const text = clipboardData.getData('text/plain'); | |||||
| if (!text) { | |||||
| return false; | |||||
| } | |||||
| // Check if text contains line breaks | |||||
| if (text.includes('\n')) { | |||||
| editor.update(() => { | |||||
| const selection = $getSelection(); | |||||
| if (selection && $isRangeSelection(selection)) { | |||||
| // Normalize line breaks, merge multiple consecutive line breaks into a single line break | |||||
| const normalizedText = text.replace(/\n{2,}/g, '\n'); | |||||
| // Clear current selection | |||||
| selection.removeText(); | |||||
| // Create a paragraph node to contain all content | |||||
| const paragraph = $createParagraphNode(); | |||||
| // Split text by line breaks | |||||
| const lines = normalizedText.split('\n'); | |||||
| // Process each line | |||||
| lines.forEach((lineText, index) => { | |||||
| // Add line text (if any) | |||||
| if (lineText) { | |||||
| const textNode = $createTextNode(lineText); | |||||
| paragraph.append(textNode); | |||||
| } | |||||
| // If not the last line, add a line break | |||||
| if (index < lines.length - 1) { | |||||
| const lineBreak = $createTextNode('\n'); | |||||
| paragraph.append(lineBreak); | |||||
| } | |||||
| }); | |||||
| // Insert paragraph | |||||
| selection.insertNodes([paragraph]); | |||||
| } | |||||
| }); | |||||
| // Prevent default paste behavior | |||||
| clipboardEvent.preventDefault(); | |||||
| return true; | |||||
| } | |||||
| // If no line breaks, use default behavior | |||||
| return false; | |||||
| }, | |||||
| 4, | |||||
| ); | |||||
| return () => { | |||||
| removeListener(); | |||||
| }; | |||||
| }, [editor]); | |||||
| return null; | |||||
| } | |||||
| export { PasteHandlerPlugin }; |
| } from './hooks/use-save-graph'; | } from './hooks/use-save-graph'; | ||||
| import { useShowEmbedModal } from './hooks/use-show-dialog'; | import { useShowEmbedModal } from './hooks/use-show-dialog'; | ||||
| import { UploadAgentDialog } from './upload-agent-dialog'; | import { UploadAgentDialog } from './upload-agent-dialog'; | ||||
| import { useAgentHistoryManager } from './use-agent-history-manager'; | |||||
| import { VersionDialog } from './version-dialog'; | import { VersionDialog } from './version-dialog'; | ||||
| function AgentDropdownMenuItem({ | function AgentDropdownMenuItem({ | ||||
| showModal: showChatDrawer, | showModal: showChatDrawer, | ||||
| } = useSetModalState(); | } = useSetModalState(); | ||||
| const { t } = useTranslation(); | const { t } = useTranslation(); | ||||
| // const openDocument = useOpenDocument(); | |||||
| useAgentHistoryManager(); | |||||
| const { | const { | ||||
| handleExportJson, | handleExportJson, | ||||
| handleImportJson, | handleImportJson, |
| import { useEffect, useRef } from 'react'; | |||||
| import useGraphStore from './store'; | |||||
| // History management class | |||||
| export class HistoryManager { | |||||
| private history: { nodes: any[]; edges: any[] }[] = []; | |||||
| private currentIndex: number = -1; | |||||
| private readonly maxSize: number = 50; // Limit maximum number of history records | |||||
| private setNodes: (nodes: any[]) => void; | |||||
| private setEdges: (edges: any[]) => void; | |||||
| private lastSavedState: string = ''; // Used to compare if state has changed | |||||
| constructor( | |||||
| setNodes: (nodes: any[]) => void, | |||||
| setEdges: (edges: any[]) => void, | |||||
| ) { | |||||
| this.setNodes = setNodes; | |||||
| this.setEdges = setEdges; | |||||
| } | |||||
| // Compare if two states are equal | |||||
| private statesEqual( | |||||
| state1: { nodes: any[]; edges: any[] }, | |||||
| state2: { nodes: any[]; edges: any[] }, | |||||
| ): boolean { | |||||
| return JSON.stringify(state1) === JSON.stringify(state2); | |||||
| } | |||||
| push(nodes: any[], edges: any[]) { | |||||
| const currentState = { | |||||
| nodes: JSON.parse(JSON.stringify(nodes)), | |||||
| edges: JSON.parse(JSON.stringify(edges)), | |||||
| }; | |||||
| // If state hasn't changed, don't save | |||||
| if ( | |||||
| this.history.length > 0 && | |||||
| this.statesEqual(currentState, this.history[this.currentIndex]) | |||||
| ) { | |||||
| return; | |||||
| } | |||||
| // If current index is not at the end of history, remove subsequent states | |||||
| if (this.currentIndex < this.history.length - 1) { | |||||
| this.history.splice(this.currentIndex + 1); | |||||
| } | |||||
| // Add current state | |||||
| this.history.push(currentState); | |||||
| // Limit history record size | |||||
| if (this.history.length > this.maxSize) { | |||||
| this.history.shift(); | |||||
| this.currentIndex = this.history.length - 1; | |||||
| } else { | |||||
| this.currentIndex = this.history.length - 1; | |||||
| } | |||||
| // Update last saved state | |||||
| this.lastSavedState = JSON.stringify(currentState); | |||||
| } | |||||
| undo() { | |||||
| if (this.canUndo()) { | |||||
| this.currentIndex--; | |||||
| const prevState = this.history[this.currentIndex]; | |||||
| this.setNodes(JSON.parse(JSON.stringify(prevState.nodes))); | |||||
| this.setEdges(JSON.parse(JSON.stringify(prevState.edges))); | |||||
| return true; | |||||
| } | |||||
| return false; | |||||
| } | |||||
| redo() { | |||||
| console.log('redo'); | |||||
| if (this.canRedo()) { | |||||
| this.currentIndex++; | |||||
| const nextState = this.history[this.currentIndex]; | |||||
| this.setNodes(JSON.parse(JSON.stringify(nextState.nodes))); | |||||
| this.setEdges(JSON.parse(JSON.stringify(nextState.edges))); | |||||
| return true; | |||||
| } | |||||
| return false; | |||||
| } | |||||
| canUndo() { | |||||
| return this.currentIndex > 0; | |||||
| } | |||||
| canRedo() { | |||||
| return this.currentIndex < this.history.length - 1; | |||||
| } | |||||
| // Reset history records | |||||
| reset() { | |||||
| this.history = []; | |||||
| this.currentIndex = -1; | |||||
| this.lastSavedState = ''; | |||||
| } | |||||
| } | |||||
| export const useAgentHistoryManager = () => { | |||||
| // Get current state and history state | |||||
| const nodes = useGraphStore((state) => state.nodes); | |||||
| const edges = useGraphStore((state) => state.edges); | |||||
| const setNodes = useGraphStore((state) => state.setNodes); | |||||
| const setEdges = useGraphStore((state) => state.setEdges); | |||||
| // Use useRef to keep HistoryManager instance unchanged | |||||
| const historyManagerRef = useRef<HistoryManager | null>(null); | |||||
| // Initialize HistoryManager | |||||
| if (!historyManagerRef.current) { | |||||
| historyManagerRef.current = new HistoryManager(setNodes, setEdges); | |||||
| } | |||||
| const historyManager = historyManagerRef.current; | |||||
| // Save state history - use useEffect instead of useMemo to avoid re-rendering | |||||
| useEffect(() => { | |||||
| historyManager.push(nodes, edges); | |||||
| }, [nodes, edges, historyManager]); | |||||
| // Keyboard event handling | |||||
| useEffect(() => { | |||||
| const handleKeyDown = (e: KeyboardEvent) => { | |||||
| // Check if focused on an input element | |||||
| const activeElement = document.activeElement; | |||||
| const isInputFocused = | |||||
| activeElement instanceof HTMLInputElement || | |||||
| activeElement instanceof HTMLTextAreaElement || | |||||
| activeElement?.hasAttribute('contenteditable'); | |||||
| // Skip keyboard shortcuts if typing in an input field | |||||
| if (isInputFocused) { | |||||
| return; | |||||
| } | |||||
| // Ctrl+Z or Cmd+Z undo | |||||
| if ( | |||||
| (e.ctrlKey || e.metaKey) && | |||||
| (e.key === 'z' || e.key === 'Z') && | |||||
| !e.shiftKey | |||||
| ) { | |||||
| e.preventDefault(); | |||||
| historyManager.undo(); | |||||
| } | |||||
| // Ctrl+Shift+Z or Cmd+Shift+Z redo | |||||
| else if ( | |||||
| (e.ctrlKey || e.metaKey) && | |||||
| (e.key === 'z' || e.key === 'Z') && | |||||
| e.shiftKey | |||||
| ) { | |||||
| e.preventDefault(); | |||||
| historyManager.redo(); | |||||
| } | |||||
| }; | |||||
| document.addEventListener('keydown', handleKeyDown); | |||||
| return () => { | |||||
| document.removeEventListener('keydown', handleKeyDown); | |||||
| }; | |||||
| }, [historyManager]); | |||||
| }; |