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]);
+};