From 7a27d5e4638b57d75bb4e2da7546fe5fbafa9222 Mon Sep 17 00:00:00 2001 From: chanx <1243304602@qq.com> Date: Wed, 6 Aug 2025 10:29:44 +0800 Subject: [PATCH] Feat: Added history management and paste handling features #3221 (#9266) ### 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) --- .../form/components/prompt-editor/index.tsx | 2 + .../prompt-editor/paste-handler-plugin.tsx | 83 +++++++++ web/src/pages/agent/index.tsx | 4 +- .../pages/agent/use-agent-history-manager.ts | 163 ++++++++++++++++++ 4 files changed, 250 insertions(+), 2 deletions(-) create mode 100644 web/src/pages/agent/form/components/prompt-editor/paste-handler-plugin.tsx create mode 100644 web/src/pages/agent/use-agent-history-manager.ts diff --git a/web/src/pages/agent/form/components/prompt-editor/index.tsx b/web/src/pages/agent/form/components/prompt-editor/index.tsx index 033ea2556..2fe5848b8 100644 --- a/web/src/pages/agent/form/components/prompt-editor/index.tsx +++ b/web/src/pages/agent/form/components/prompt-editor/index.tsx @@ -26,6 +26,7 @@ import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext import { Variable } from 'lucide-react'; import { ReactNode, useCallback, useState } from 'react'; import { useTranslation } from 'react-i18next'; +import { PasteHandlerPlugin } from './paste-handler-plugin'; import theme from './theme'; import { VariableNode } from './variable-node'; import { VariableOnChangePlugin } from './variable-on-change-plugin'; @@ -172,6 +173,7 @@ export function PromptEditor({ ErrorBoundary={LexicalErrorBoundary} /> + diff --git a/web/src/pages/agent/form/components/prompt-editor/paste-handler-plugin.tsx b/web/src/pages/agent/form/components/prompt-editor/paste-handler-plugin.tsx new file mode 100644 index 000000000..a45a5e5fb --- /dev/null +++ b/web/src/pages/agent/form/components/prompt-editor/paste-handler-plugin.tsx @@ -0,0 +1,83 @@ +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 }; diff --git a/web/src/pages/agent/index.tsx b/web/src/pages/agent/index.tsx index 2ac645a58..2d50cd4cb 100644 --- a/web/src/pages/agent/index.tsx +++ b/web/src/pages/agent/index.tsx @@ -44,6 +44,7 @@ import { } from './hooks/use-save-graph'; import { useShowEmbedModal } from './hooks/use-show-dialog'; import { UploadAgentDialog } from './upload-agent-dialog'; +import { useAgentHistoryManager } from './use-agent-history-manager'; import { VersionDialog } from './version-dialog'; function AgentDropdownMenuItem({ @@ -66,8 +67,7 @@ export default function Agent() { showModal: showChatDrawer, } = useSetModalState(); const { t } = useTranslation(); - - // const openDocument = useOpenDocument(); + useAgentHistoryManager(); const { handleExportJson, handleImportJson, diff --git a/web/src/pages/agent/use-agent-history-manager.ts b/web/src/pages/agent/use-agent-history-manager.ts new file mode 100644 index 000000000..50c108cf4 --- /dev/null +++ b/web/src/pages/agent/use-agent-history-manager.ts @@ -0,0 +1,163 @@ +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(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]); +};