mirror of
https://github.com/infiniflow/ragflow.git
synced 2026-01-31 15:45:08 +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,
|
NodeHandleId,
|
||||||
VariableType,
|
VariableType,
|
||||||
} from '../../constant';
|
} from '../../constant';
|
||||||
|
import { useSaveOnBlur } from '../../hooks/use-save-on-blur';
|
||||||
import { INextOperatorForm } from '../../interface';
|
import { INextOperatorForm } from '../../interface';
|
||||||
import useGraphStore from '../../store';
|
import useGraphStore from '../../store';
|
||||||
import { hasSubAgentOrTool, isBottomSubAgent } from '../../utils';
|
import { hasSubAgentOrTool, isBottomSubAgent } from '../../utils';
|
||||||
@ -94,6 +95,8 @@ function AgentForm({ node }: INextOperatorForm) {
|
|||||||
|
|
||||||
const { extraOptions } = useBuildPromptExtraPromptOptions(edges, node?.id);
|
const { extraOptions } = useBuildPromptExtraPromptOptions(edges, node?.id);
|
||||||
|
|
||||||
|
const { handleSaveOnBlur } = useSaveOnBlur();
|
||||||
|
|
||||||
const ExceptionMethodOptions = Object.values(AgentExceptionMethod).map(
|
const ExceptionMethodOptions = Object.values(AgentExceptionMethod).map(
|
||||||
(x) => ({
|
(x) => ({
|
||||||
label: t(`flow.${x}`),
|
label: t(`flow.${x}`),
|
||||||
@ -163,7 +166,7 @@ function AgentForm({ node }: INextOperatorForm) {
|
|||||||
<QueryVariable
|
<QueryVariable
|
||||||
name="visual_files_var"
|
name="visual_files_var"
|
||||||
label="Visual Input File"
|
label="Visual Input File"
|
||||||
type={VariableType.File}
|
types={[VariableType.File]}
|
||||||
></QueryVariable>
|
></QueryVariable>
|
||||||
)}
|
)}
|
||||||
<FormField
|
<FormField
|
||||||
@ -178,6 +181,7 @@ function AgentForm({ node }: INextOperatorForm) {
|
|||||||
placeholder={t('flow.messagePlaceholder')}
|
placeholder={t('flow.messagePlaceholder')}
|
||||||
showToolbar={true}
|
showToolbar={true}
|
||||||
extraOptions={extraOptions}
|
extraOptions={extraOptions}
|
||||||
|
onBlur={handleSaveOnBlur}
|
||||||
></PromptEditor>
|
></PromptEditor>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
</FormItem>
|
</FormItem>
|
||||||
@ -195,6 +199,7 @@ function AgentForm({ node }: INextOperatorForm) {
|
|||||||
<PromptEditor
|
<PromptEditor
|
||||||
{...field}
|
{...field}
|
||||||
showToolbar={true}
|
showToolbar={true}
|
||||||
|
onBlur={handleSaveOnBlur}
|
||||||
></PromptEditor>
|
></PromptEditor>
|
||||||
</section>
|
</section>
|
||||||
</FormControl>
|
</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 { Variable } from 'lucide-react';
|
||||||
import { ReactNode, useCallback, useState } from 'react';
|
import { ReactNode, useCallback, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { EnterKeyPlugin } from './enter-key-plugin';
|
||||||
import { PasteHandlerPlugin } from './paste-handler-plugin';
|
import { PasteHandlerPlugin } from './paste-handler-plugin';
|
||||||
import theme from './theme';
|
import theme from './theme';
|
||||||
import { VariableNode } from './variable-node';
|
import { VariableNode } from './variable-node';
|
||||||
@ -49,11 +50,16 @@ const Nodes: Array<Klass<LexicalNode>> = [
|
|||||||
VariableNode,
|
VariableNode,
|
||||||
];
|
];
|
||||||
|
|
||||||
type PromptContentProps = { showToolbar?: boolean; multiLine?: boolean };
|
type PromptContentProps = {
|
||||||
|
showToolbar?: boolean;
|
||||||
|
multiLine?: boolean;
|
||||||
|
onBlur?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
type IProps = {
|
type IProps = {
|
||||||
value?: string;
|
value?: string;
|
||||||
onChange?: (value?: string) => void;
|
onChange?: (value?: string) => void;
|
||||||
|
onBlur?: () => void;
|
||||||
placeholder?: ReactNode;
|
placeholder?: ReactNode;
|
||||||
types?: JsonSchemaDataType[];
|
types?: JsonSchemaDataType[];
|
||||||
} & PromptContentProps &
|
} & PromptContentProps &
|
||||||
@ -62,6 +68,7 @@ type IProps = {
|
|||||||
function PromptContent({
|
function PromptContent({
|
||||||
showToolbar = true,
|
showToolbar = true,
|
||||||
multiLine = true,
|
multiLine = true,
|
||||||
|
onBlur,
|
||||||
}: PromptContentProps) {
|
}: PromptContentProps) {
|
||||||
const [editor] = useLexicalComposerContext();
|
const [editor] = useLexicalComposerContext();
|
||||||
const [isBlur, setIsBlur] = useState(false);
|
const [isBlur, setIsBlur] = useState(false);
|
||||||
@ -83,7 +90,8 @@ function PromptContent({
|
|||||||
|
|
||||||
const handleBlur = useCallback(() => {
|
const handleBlur = useCallback(() => {
|
||||||
setIsBlur(true);
|
setIsBlur(true);
|
||||||
}, []);
|
onBlur?.();
|
||||||
|
}, [onBlur]);
|
||||||
|
|
||||||
const handleFocus = useCallback(() => {
|
const handleFocus = useCallback(() => {
|
||||||
setIsBlur(false);
|
setIsBlur(false);
|
||||||
@ -124,6 +132,7 @@ function PromptContent({
|
|||||||
export function PromptEditor({
|
export function PromptEditor({
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
|
onBlur,
|
||||||
placeholder,
|
placeholder,
|
||||||
showToolbar,
|
showToolbar,
|
||||||
multiLine = true,
|
multiLine = true,
|
||||||
@ -161,6 +170,7 @@ export function PromptEditor({
|
|||||||
<PromptContent
|
<PromptContent
|
||||||
showToolbar={showToolbar}
|
showToolbar={showToolbar}
|
||||||
multiLine={multiLine}
|
multiLine={multiLine}
|
||||||
|
onBlur={onBlur}
|
||||||
></PromptContent>
|
></PromptContent>
|
||||||
}
|
}
|
||||||
placeholder={
|
placeholder={
|
||||||
@ -185,6 +195,7 @@ export function PromptEditor({
|
|||||||
types={types}
|
types={types}
|
||||||
></VariablePickerMenuPlugin>
|
></VariablePickerMenuPlugin>
|
||||||
<PasteHandlerPlugin />
|
<PasteHandlerPlugin />
|
||||||
|
<EnterKeyPlugin />
|
||||||
<VariableOnChangePlugin
|
<VariableOnChangePlugin
|
||||||
onChange={onValueChange}
|
onChange={onValueChange}
|
||||||
></VariableOnChangePlugin>
|
></VariableOnChangePlugin>
|
||||||
|
|||||||
@ -1,15 +1,17 @@
|
|||||||
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
|
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
|
||||||
import {
|
import {
|
||||||
$createParagraphNode,
|
$createLineBreakNode,
|
||||||
$createTextNode,
|
$createTextNode,
|
||||||
$getSelection,
|
$getSelection,
|
||||||
$isRangeSelection,
|
$isRangeSelection,
|
||||||
|
LexicalNode,
|
||||||
PASTE_COMMAND,
|
PASTE_COMMAND,
|
||||||
} from 'lexical';
|
} from 'lexical';
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
function PasteHandlerPlugin() {
|
function PasteHandlerPlugin() {
|
||||||
const [editor] = useLexicalComposerContext();
|
const [editor] = useLexicalComposerContext();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const removeListener = editor.registerCommand(
|
const removeListener = editor.registerCommand(
|
||||||
PASTE_COMMAND,
|
PASTE_COMMAND,
|
||||||
@ -24,40 +26,29 @@ function PasteHandlerPlugin() {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if text contains line breaks
|
// Handle text with line breaks
|
||||||
if (text.includes('\n')) {
|
if (text.includes('\n')) {
|
||||||
editor.update(() => {
|
editor.update(() => {
|
||||||
const selection = $getSelection();
|
const selection = $getSelection();
|
||||||
if (selection && $isRangeSelection(selection)) {
|
if (selection && $isRangeSelection(selection)) {
|
||||||
// Normalize line breaks, merge multiple consecutive line breaks into a single line break
|
// Build an array of nodes (TextNodes and LineBreakNodes).
|
||||||
const normalizedText = text.replace(/\n{2,}/g, '\n');
|
// 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) => {
|
lines.forEach((lineText, index) => {
|
||||||
// Add line text (if any)
|
|
||||||
if (lineText) {
|
if (lineText) {
|
||||||
const textNode = $createTextNode(lineText);
|
nodes.push($createTextNode(lineText));
|
||||||
paragraph.append(textNode);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// If not the last line, add a line break
|
// Add LineBreakNode between lines (not after the last line)
|
||||||
if (index < lines.length - 1) {
|
if (index < lines.length - 1) {
|
||||||
const lineBreak = $createTextNode('\n');
|
nodes.push($createLineBreakNode());
|
||||||
paragraph.append(lineBreak);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Insert paragraph
|
selection.insertNodes(nodes);
|
||||||
selection.insertNodes([paragraph]);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -12,6 +12,7 @@ import {
|
|||||||
MenuOption,
|
MenuOption,
|
||||||
} from '@lexical/react/LexicalTypeaheadMenuPlugin';
|
} from '@lexical/react/LexicalTypeaheadMenuPlugin';
|
||||||
import {
|
import {
|
||||||
|
$createLineBreakNode,
|
||||||
$createParagraphNode,
|
$createParagraphNode,
|
||||||
$createTextNode,
|
$createTextNode,
|
||||||
$getRoot,
|
$getRoot,
|
||||||
@ -294,27 +295,22 @@ export default function VariablePickerMenuPlugin({
|
|||||||
[editor],
|
[editor],
|
||||||
);
|
);
|
||||||
|
|
||||||
const parseTextToVariableNodes = useCallback(
|
// Parses a single line of text and appends nodes to the paragraph.
|
||||||
(text: string) => {
|
// Handles variable references in the format {variable_name}.
|
||||||
const paragraph = $createParagraphNode();
|
const parseLineContent = useCallback(
|
||||||
|
(line: string, paragraph: ReturnType<typeof $createParagraphNode>) => {
|
||||||
// Regular expression to match content within {}
|
|
||||||
const regex = /{([^}]*)}/g;
|
const regex = /{([^}]*)}/g;
|
||||||
let match;
|
let match;
|
||||||
let lastIndex = 0;
|
let lastIndex = 0;
|
||||||
while ((match = regex.exec(text)) !== null) {
|
|
||||||
|
while ((match = regex.exec(line)) !== null) {
|
||||||
const { 1: content, index, 0: template } = match;
|
const { 1: content, index, 0: template } = match;
|
||||||
|
|
||||||
// Add the previous text part (if any)
|
|
||||||
if (index > lastIndex) {
|
if (index > lastIndex) {
|
||||||
const textNode = $createTextNode(text.slice(lastIndex, index));
|
paragraph.append($createTextNode(line.slice(lastIndex, index)));
|
||||||
|
|
||||||
paragraph.append(textNode);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add variable node or text node
|
|
||||||
const nodeItem = findItemByValue(content);
|
const nodeItem = findItemByValue(content);
|
||||||
|
|
||||||
if (nodeItem) {
|
if (nodeItem) {
|
||||||
paragraph.append(
|
paragraph.append(
|
||||||
$createVariableNode(
|
$createVariableNode(
|
||||||
@ -328,15 +324,34 @@ export default function VariablePickerMenuPlugin({
|
|||||||
paragraph.append($createTextNode(template));
|
paragraph.append($createTextNode(template));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update index
|
|
||||||
lastIndex = regex.lastIndex;
|
lastIndex = regex.lastIndex;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add the last part of text (if any)
|
if (lastIndex < line.length) {
|
||||||
if (lastIndex < text.length) {
|
paragraph.append($createTextNode(line.slice(lastIndex)));
|
||||||
const textNode = $createTextNode(text.slice(lastIndex));
|
|
||||||
paragraph.append(textNode);
|
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
[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);
|
$getRoot().clear().append(paragraph);
|
||||||
|
|
||||||
@ -344,7 +359,7 @@ export default function VariablePickerMenuPlugin({
|
|||||||
$getRoot().selectEnd();
|
$getRoot().selectEnd();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[findItemByValue],
|
[parseLineContent],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@ -8,16 +8,19 @@ import {
|
|||||||
import { ReactNode } from 'react';
|
import { ReactNode } from 'react';
|
||||||
import { useFormContext } from 'react-hook-form';
|
import { useFormContext } from 'react-hook-form';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { JsonSchemaDataType } from '../../constant';
|
import { JsonSchemaDataType, VariableType } from '../../constant';
|
||||||
import {
|
import {
|
||||||
BuildQueryVariableOptions,
|
BuildQueryVariableOptions,
|
||||||
useFilterQueryVariableOptionsByTypes,
|
useFilterQueryVariableOptionsByTypes,
|
||||||
} from '../../hooks/use-get-begin-query';
|
} from '../../hooks/use-get-begin-query';
|
||||||
import { GroupedSelectWithSecondaryMenu } from './select-with-secondary-menu';
|
import { GroupedSelectWithSecondaryMenu } from './select-with-secondary-menu';
|
||||||
|
|
||||||
|
// Union type to support both JsonSchemaDataType and VariableType for filtering
|
||||||
|
type QueryVariableType = JsonSchemaDataType | VariableType;
|
||||||
|
|
||||||
type QueryVariableProps = {
|
type QueryVariableProps = {
|
||||||
name?: string;
|
name?: string;
|
||||||
types?: JsonSchemaDataType[];
|
types?: QueryVariableType[];
|
||||||
label?: ReactNode;
|
label?: ReactNode;
|
||||||
hideLabel?: boolean;
|
hideLabel?: boolean;
|
||||||
className?: string;
|
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