mirror of
https://github.com/infiniflow/ragflow.git
synced 2026-01-30 23:26:36 +08:00
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:
@ -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>
|
||||
|
||||
@ -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 };
|
||||
@ -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>
|
||||
|
||||
@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@ -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(() => {
|
||||
|
||||
@ -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;
|
||||
|
||||
14
web/src/pages/agent/hooks/use-save-on-blur.ts
Normal file
14
web/src/pages/agent/hooks/use-save-on-blur.ts
Normal 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 };
|
||||
};
|
||||
Reference in New Issue
Block a user