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)
This commit is contained in:
BitToby
2026-01-30 04:29:51 +02:00
committed by GitHub
parent 4947e9473a
commit 73645e2f78
7 changed files with 134 additions and 45 deletions

View File

@ -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) {
<QueryVariable
name="visual_files_var"
label="Visual Input File"
type={VariableType.File}
types={[VariableType.File]}
></QueryVariable>
)}
<FormField
@ -178,6 +181,7 @@ function AgentForm({ node }: INextOperatorForm) {
placeholder={t('flow.messagePlaceholder')}
showToolbar={true}
extraOptions={extraOptions}
onBlur={handleSaveOnBlur}
></PromptEditor>
</FormControl>
</FormItem>
@ -195,6 +199,7 @@ function AgentForm({ node }: INextOperatorForm) {
<PromptEditor
{...field}
showToolbar={true}
onBlur={handleSaveOnBlur}
></PromptEditor>
</section>
</FormControl>

View File

@ -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 };

View File

@ -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<Klass<LexicalNode>> = [
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({
<PromptContent
showToolbar={showToolbar}
multiLine={multiLine}
onBlur={onBlur}
></PromptContent>
}
placeholder={
@ -185,6 +195,7 @@ export function PromptEditor({
types={types}
></VariablePickerMenuPlugin>
<PasteHandlerPlugin />
<EnterKeyPlugin />
<VariableOnChangePlugin
onChange={onValueChange}
></VariableOnChangePlugin>

View File

@ -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);
}
});

View File

@ -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<typeof $createParagraphNode>) => {
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(() => {

View File

@ -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;

View File

@ -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 };
};