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