From 73645e2f78f3d293ecff69a3e7a82760f5d586d4 Mon Sep 17 00:00:00 2001 From: BitToby Date: Fri, 30 Jan 2026 04:29:51 +0200 Subject: [PATCH] fix: preserve line breaks in prompt editor and add auto-save on blur (#12887) Closes #12762 ### What problem does this PR solve? **Line break issue in Agent prompt editor:** - Text with blank lines in `system_prompt` or `user_prompt` would have extra/fewer blank lines after save/reload or paste - Root cause: Mismatch between Lexical editor's paragraph nodes (`\n\n` separator) and line break nodes (`\n` separator) **Auto-save issue:** - Changes were only saved after 20-second debounce, causing data loss on page refresh before timer completed ### Solution 1. **Line break fix**: Use `LineBreakNode` consistently for all line breaks (typing Enter, paste, load) 2. **Auto-save**: Save immediately when prompt editor loses focus [1.webm](https://github.com/user-attachments/assets/eb2c2428-54a3-4d4e-8037-6cc34a859b83) ### Type of change - [x] Bug Fix (non-breaking change which fixes an issue) --- web/src/pages/agent/form/agent-form/index.tsx | 7 ++- .../prompt-editor/enter-key-plugin.tsx | 50 ++++++++++++++++++ .../form/components/prompt-editor/index.tsx | 15 +++++- .../prompt-editor/paste-handler-plugin.tsx | 35 +++++-------- .../prompt-editor/variable-picker-plugin.tsx | 51 ++++++++++++------- .../agent/form/components/query-variable.tsx | 7 ++- web/src/pages/agent/hooks/use-save-on-blur.ts | 14 +++++ 7 files changed, 134 insertions(+), 45 deletions(-) create mode 100644 web/src/pages/agent/form/components/prompt-editor/enter-key-plugin.tsx create mode 100644 web/src/pages/agent/hooks/use-save-on-blur.ts diff --git a/web/src/pages/agent/form/agent-form/index.tsx b/web/src/pages/agent/form/agent-form/index.tsx index 23227eb51..6fd0f2aaf 100644 --- a/web/src/pages/agent/form/agent-form/index.tsx +++ b/web/src/pages/agent/form/agent-form/index.tsx @@ -33,6 +33,7 @@ import { NodeHandleId, VariableType, } from '../../constant'; +import { useSaveOnBlur } from '../../hooks/use-save-on-blur'; import { INextOperatorForm } from '../../interface'; import useGraphStore from '../../store'; import { hasSubAgentOrTool, isBottomSubAgent } from '../../utils'; @@ -94,6 +95,8 @@ function AgentForm({ node }: INextOperatorForm) { const { extraOptions } = useBuildPromptExtraPromptOptions(edges, node?.id); + const { handleSaveOnBlur } = useSaveOnBlur(); + const ExceptionMethodOptions = Object.values(AgentExceptionMethod).map( (x) => ({ label: t(`flow.${x}`), @@ -163,7 +166,7 @@ function AgentForm({ node }: INextOperatorForm) { )} @@ -195,6 +199,7 @@ function AgentForm({ node }: INextOperatorForm) { diff --git a/web/src/pages/agent/form/components/prompt-editor/enter-key-plugin.tsx b/web/src/pages/agent/form/components/prompt-editor/enter-key-plugin.tsx new file mode 100644 index 000000000..25913be6c --- /dev/null +++ b/web/src/pages/agent/form/components/prompt-editor/enter-key-plugin.tsx @@ -0,0 +1,50 @@ +import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'; +import { + $createLineBreakNode, + $getSelection, + $isRangeSelection, + COMMAND_PRIORITY_HIGH, + KEY_ENTER_COMMAND, +} from 'lexical'; +import { useEffect } from 'react'; + +// This plugin overrides the default Enter key behavior. +// Instead of creating a new paragraph (which adds \n\n in getTextContent), +// it creates a LineBreakNode (which adds \n in getTextContent). +// This ensures consistent serialization between typed and pasted content. +function EnterKeyPlugin() { + const [editor] = useLexicalComposerContext(); + + useEffect(() => { + const removeListener = editor.registerCommand( + KEY_ENTER_COMMAND, + (event: KeyboardEvent | null) => { + // Allow Shift+Enter to use default behavior (if needed for other purposes) + if (event?.shiftKey) { + return false; + } + + const selection = $getSelection(); + if (selection && $isRangeSelection(selection)) { + // Prevent default paragraph creation + event?.preventDefault(); + + // Insert a LineBreakNode at cursor position + selection.insertNodes([$createLineBreakNode()]); + return true; + } + + return false; + }, + COMMAND_PRIORITY_HIGH, + ); + + return () => { + removeListener(); + }; + }, [editor]); + + return null; +} + +export { EnterKeyPlugin }; 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 c9644249d..45ed90686 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 { EnterKeyPlugin } from './enter-key-plugin'; import { PasteHandlerPlugin } from './paste-handler-plugin'; import theme from './theme'; import { VariableNode } from './variable-node'; @@ -49,11 +50,16 @@ const Nodes: Array> = [ VariableNode, ]; -type PromptContentProps = { showToolbar?: boolean; multiLine?: boolean }; +type PromptContentProps = { + showToolbar?: boolean; + multiLine?: boolean; + onBlur?: () => void; +}; type IProps = { value?: string; onChange?: (value?: string) => void; + onBlur?: () => void; placeholder?: ReactNode; types?: JsonSchemaDataType[]; } & PromptContentProps & @@ -62,6 +68,7 @@ type IProps = { function PromptContent({ showToolbar = true, multiLine = true, + onBlur, }: PromptContentProps) { const [editor] = useLexicalComposerContext(); const [isBlur, setIsBlur] = useState(false); @@ -83,7 +90,8 @@ function PromptContent({ const handleBlur = useCallback(() => { setIsBlur(true); - }, []); + onBlur?.(); + }, [onBlur]); const handleFocus = useCallback(() => { setIsBlur(false); @@ -124,6 +132,7 @@ function PromptContent({ export function PromptEditor({ value, onChange, + onBlur, placeholder, showToolbar, multiLine = true, @@ -161,6 +170,7 @@ export function PromptEditor({ } placeholder={ @@ -185,6 +195,7 @@ export function PromptEditor({ types={types} > + 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 index a45a5e5fb..82c99151f 100644 --- 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 @@ -1,15 +1,17 @@ import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'; import { - $createParagraphNode, + $createLineBreakNode, $createTextNode, $getSelection, $isRangeSelection, + LexicalNode, PASTE_COMMAND, } from 'lexical'; import { useEffect } from 'react'; function PasteHandlerPlugin() { const [editor] = useLexicalComposerContext(); + useEffect(() => { const removeListener = editor.registerCommand( PASTE_COMMAND, @@ -24,40 +26,29 @@ function PasteHandlerPlugin() { return false; } - // Check if text contains line breaks + // Handle text with 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'); + // Build an array of nodes (TextNodes and LineBreakNodes). + // Insert nodes directly into selection to avoid creating + // extra paragraph boundaries which cause newline multiplication. + const nodes: LexicalNode[] = []; + const lines = text.split('\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); + nodes.push($createTextNode(lineText)); } - // If not the last line, add a line break + // Add LineBreakNode between lines (not after the last line) if (index < lines.length - 1) { - const lineBreak = $createTextNode('\n'); - paragraph.append(lineBreak); + nodes.push($createLineBreakNode()); } }); - // Insert paragraph - selection.insertNodes([paragraph]); + selection.insertNodes(nodes); } }); diff --git a/web/src/pages/agent/form/components/prompt-editor/variable-picker-plugin.tsx b/web/src/pages/agent/form/components/prompt-editor/variable-picker-plugin.tsx index 7b86a90e9..621bdcc10 100644 --- a/web/src/pages/agent/form/components/prompt-editor/variable-picker-plugin.tsx +++ b/web/src/pages/agent/form/components/prompt-editor/variable-picker-plugin.tsx @@ -12,6 +12,7 @@ import { MenuOption, } from '@lexical/react/LexicalTypeaheadMenuPlugin'; import { + $createLineBreakNode, $createParagraphNode, $createTextNode, $getRoot, @@ -294,27 +295,22 @@ export default function VariablePickerMenuPlugin({ [editor], ); - const parseTextToVariableNodes = useCallback( - (text: string) => { - const paragraph = $createParagraphNode(); - - // Regular expression to match content within {} + // Parses a single line of text and appends nodes to the paragraph. + // Handles variable references in the format {variable_name}. + const parseLineContent = useCallback( + (line: string, paragraph: ReturnType) => { const regex = /{([^}]*)}/g; let match; let lastIndex = 0; - while ((match = regex.exec(text)) !== null) { + + while ((match = regex.exec(line)) !== null) { const { 1: content, index, 0: template } = match; - // Add the previous text part (if any) if (index > lastIndex) { - const textNode = $createTextNode(text.slice(lastIndex, index)); - - paragraph.append(textNode); + paragraph.append($createTextNode(line.slice(lastIndex, index))); } - // Add variable node or text node const nodeItem = findItemByValue(content); - if (nodeItem) { paragraph.append( $createVariableNode( @@ -328,15 +324,34 @@ export default function VariablePickerMenuPlugin({ paragraph.append($createTextNode(template)); } - // Update index lastIndex = regex.lastIndex; } - // Add the last part of text (if any) - if (lastIndex < text.length) { - const textNode = $createTextNode(text.slice(lastIndex)); - paragraph.append(textNode); + if (lastIndex < line.length) { + paragraph.append($createTextNode(line.slice(lastIndex))); } + }, + [findItemByValue], + ); + + // Parses text content into a single paragraph with LineBreakNodes for newlines. + // Using LineBreakNode ensures proper rendering and consistent serialization. + const parseTextToVariableNodes = useCallback( + (text: string) => { + const paragraph = $createParagraphNode(); + const lines = text.split('\n'); + + lines.forEach((line, index) => { + // Parse the line content (text and variables) + if (line) { + parseLineContent(line, paragraph); + } + + // Add LineBreakNode between lines (not after the last line) + if (index < lines.length - 1) { + paragraph.append($createLineBreakNode()); + } + }); $getRoot().clear().append(paragraph); @@ -344,7 +359,7 @@ export default function VariablePickerMenuPlugin({ $getRoot().selectEnd(); } }, - [findItemByValue], + [parseLineContent], ); useEffect(() => { diff --git a/web/src/pages/agent/form/components/query-variable.tsx b/web/src/pages/agent/form/components/query-variable.tsx index 8c8f8d08f..43f3bf1e5 100644 --- a/web/src/pages/agent/form/components/query-variable.tsx +++ b/web/src/pages/agent/form/components/query-variable.tsx @@ -8,16 +8,19 @@ import { import { ReactNode } from 'react'; import { useFormContext } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; -import { JsonSchemaDataType } from '../../constant'; +import { JsonSchemaDataType, VariableType } from '../../constant'; import { BuildQueryVariableOptions, useFilterQueryVariableOptionsByTypes, } from '../../hooks/use-get-begin-query'; import { GroupedSelectWithSecondaryMenu } from './select-with-secondary-menu'; +// Union type to support both JsonSchemaDataType and VariableType for filtering +type QueryVariableType = JsonSchemaDataType | VariableType; + type QueryVariableProps = { name?: string; - types?: JsonSchemaDataType[]; + types?: QueryVariableType[]; label?: ReactNode; hideLabel?: boolean; className?: string; diff --git a/web/src/pages/agent/hooks/use-save-on-blur.ts b/web/src/pages/agent/hooks/use-save-on-blur.ts new file mode 100644 index 000000000..1c495585a --- /dev/null +++ b/web/src/pages/agent/hooks/use-save-on-blur.ts @@ -0,0 +1,14 @@ +import { useCallback } from 'react'; +import { useSaveGraph } from './use-save-graph'; + +// Hook to save the graph when a form field loses focus. +// This ensures changes are persisted immediately without waiting for the debounce timer. +export const useSaveOnBlur = () => { + const { saveGraph } = useSaveGraph(false); + + const handleSaveOnBlur = useCallback(() => { + saveGraph(); + }, [saveGraph]); + + return { handleSaveOnBlur }; +};