Compare commits

...

5 Commits

Author SHA1 Message Date
zhaoying
fa4be10e51 fix(web): string type language Editor init 2026-04-02 17:18:08 +08:00
zhaoying
dcb7b496d3 fix(web): jinja2 editor 2026-04-02 15:16:04 +08:00
zhaoying
9535545947 fix(web): if-else cases 2026-04-02 12:13:11 +08:00
zhaoying
59f5c7a8bb fix(web): knowledge base's model types 2026-04-02 11:05:11 +08:00
zhaoying
1305a08c86 fix(web): knowledge base model api params 2026-04-02 10:22:21 +08:00
14 changed files with 641 additions and 305 deletions

View File

@@ -68,7 +68,7 @@ export const getModelTypeList = async () => {
return response as any[];
};
// 获取模型列表
export const getModelList = async (types: string[], pageInfo: PageRequest) => {
export const getModelList = async (pageInfo: PageRequest, types?: string[]) => {
const response = await request.get(`${apiPrefix}/models`, { ...pageInfo, type: types?.join(','), is_active: true });
return response as any;
};

View File

@@ -162,7 +162,7 @@ const CreateModal = forwardRef<CreateModalRef, CreateModalRefProps>(({
// If model data hasn't been fetched yet, fetch it once
if (!models) {
try {
models = await getModelList({ page: 1, pagesize: 100 });
models = await getModelList({ page: 1, pagesize: 100 }, ['llm', 'embedding', 'rerank', 'chat']);
} catch (error) {
console.error('Failed to fetch models:', error);
models = { items: [] };

View File

@@ -207,7 +207,7 @@ const KnowledgeBaseManagement: FC = () => {
};
const fetchModelList = async () => {
try {
const response = await getModelList(['llm', 'embedding', 'rerank', 'chat'], { page: 1, pagesize: 100 });
const response = await getModelList({ page: 1, pagesize: 100 }, ['llm', 'embedding', 'rerank', 'chat']);
// 缓存模型列表,建立 id -> name 的映射
if (response?.items && Array.isArray(response.items)) {
const cache: Record<string, string> = {};

View File

@@ -0,0 +1,185 @@
/*
* @Author: ZhaoYing
* @Date: 2026-04-02 15:15:36
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-04-02 15:15:36
*/
import { type FC, useEffect, useMemo } from 'react';
import { LexicalComposer } from '@lexical/react/LexicalComposer';
import { RichTextPlugin } from '@lexical/react/LexicalRichTextPlugin';
import { ContentEditable } from '@lexical/react/LexicalContentEditable';
import { HistoryPlugin } from '@lexical/react/LexicalHistoryPlugin';
import { LexicalErrorBoundary } from '@lexical/react/LexicalErrorBoundary';
import { type Suggestion } from './plugin/AutocompletePlugin';
import CharacterCountPlugin from './plugin/CharacterCountPlugin';
import Jinja2InitialValuePlugin from './plugin/Jinja2InitialValuePlugin';
import Jinja2AutocompletePlugin from './plugin/Jinja2AutocompletePlugin';
import Jinja2HighlightPlugin from './plugin/Jinja2HighlightPlugin';
import Jinja2BlurPlugin from './plugin/Jinja2BlurPlugin';
import LineNumberPlugin from './plugin/LineNumberPlugin';
const jinja2Theme = {
paragraph: 'editor-paragraph',
code: 'jinja2-expression',
text: {
bold: 'editor-text-bold',
italic: 'editor-text-italic',
code: 'jinja2-inline',
},
};
const initialConfig = {
namespace: 'AutocompleteEditor',
theme: jinja2Theme,
nodes: [],
onError: (error: Error) => console.error(error),
};
const STYLE_ID = 'code-editor-styles';
const JINJA2_STYLES = `
.jinja2-expression {
background-color: #f6f8fa !important;
border: 1px solid #d1d9e0 !important;
border-radius: 3px !important;
padding: 2px 4px !important;
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace !important;
font-size: 13px !important;
color: #0969da !important;
}
.jinja2-inline {
background-color: #f6f8fa !important;
padding: 1px 3px !important;
border-radius: 2px !important;
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace !important;
font-size: 13px !important;
color: #0969da !important;
}
.editor-paragraph { margin: 0; }
.editor-with-line-numbers { display: flex; }
.line-numbers {
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
font-size: 12px;
line-height: 16px;
padding: 4px 8px;
text-align: right;
user-select: none;
display: flex;
flex-direction: column;
}
.line-numbers > div { min-height: 20px; display: flex; align-items: flex-start; }
.editor-content-wrapper { flex: 1; }
.editor-content-with-numbers {
white-space: pre-wrap;
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
}
.editor-content-with-numbers p { margin: 0; min-height: 20px; }
`;
export interface Jinja2EditorProps {
placeholder?: string;
value?: string;
onChange?: (value: string) => void;
options?: Suggestion[];
variant?: 'outlined' | 'borderless';
height?: number;
size?: 'default' | 'small';
className?: string;
}
const Jinja2Editor: FC<Jinja2EditorProps> = ({
placeholder = '请输入内容...',
value = '',
onChange,
options = [],
variant = 'borderless',
size = 'default',
height,
className,
}) => {
useEffect(() => {
if (!document.getElementById(STYLE_ID)) {
const style = document.createElement('style');
style.id = STYLE_ID;
style.textContent = JINJA2_STYLES;
document.head.appendChild(style);
}
}, []);
const minheight = useMemo(
() => `${height ?? (size === 'small' ? 60 : 120)}px`,
[height, size],
);
const fontSize = size === 'small' ? '12px' : '14px';
const lineHeight = useMemo(
() => `${height ? height - 10 : size === 'small' ? 16 : 20}px`,
[height, size],
);
const placeHolderMinheight = `${height ? 16 : size === 'small' ? 16 : 30}px`;
return (
<LexicalComposer initialConfig={initialConfig}>
<div style={{ position: 'relative' }} className={className}>
<RichTextPlugin
contentEditable={
<div
className="editor-with-line-numbers"
style={{
border: variant === 'borderless' ? 'none' : '1px solid #DFE4ED',
borderRadius: '6px',
minHeight: minheight,
}}
>
<div className="line-numbers">
<div>1</div>
</div>
<div className="editor-content-wrapper">
<ContentEditable
className="editor-content-with-numbers"
style={{
minHeight: minheight,
padding: variant === 'borderless' ? '0' : '4px 0',
outline: 'none',
resize: 'none',
fontSize,
lineHeight,
border: 'none',
}}
/>
</div>
</div>
}
placeholder={
<div
style={{
minHeight: placeHolderMinheight,
position: 'absolute',
top: '4px',
left: '16px',
color: '#A8A9AA',
fontSize,
lineHeight: placeHolderMinheight,
pointerEvents: 'none',
}}
>
{placeholder}
</div>
}
ErrorBoundary={LexicalErrorBoundary}
/>
<HistoryPlugin />
<Jinja2HighlightPlugin />
<LineNumberPlugin />
<Jinja2AutocompletePlugin options={options} />
<CharacterCountPlugin setCount={() => {}} onChange={onChange} waitForInit />
<Jinja2InitialValuePlugin value={value} />
<Jinja2BlurPlugin />
</div>
</LexicalComposer>
);
};
export default Jinja2Editor;

View File

@@ -4,26 +4,20 @@
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-03-25 10:58:47
*/
import { type FC, useState, useEffect, useMemo } from 'react';
import { type FC, useState, useMemo } from 'react';
import { LexicalComposer } from '@lexical/react/LexicalComposer';
import { RichTextPlugin } from '@lexical/react/LexicalRichTextPlugin';
import { ContentEditable } from '@lexical/react/LexicalContentEditable';
import { HistoryPlugin } from '@lexical/react/LexicalHistoryPlugin';
// import { AutoFocusPlugin } from '@lexical/react/LexicalAutoFocusPlugin';
import { LexicalErrorBoundary } from '@lexical/react/LexicalErrorBoundary';
// import { HeadingNode, QuoteNode } from '@lexical/rich-text';
// import { ListItemNode, ListNode } from '@lexical/list';
// import { LinkNode } from '@lexical/link';
// import { CodeNode } from '@lexical/code';
import AutocompletePlugin, { type Suggestion } from './plugin/AutocompletePlugin'
import CharacterCountPlugin from './plugin/CharacterCountPlugin'
import InitialValuePlugin from './plugin/InitialValuePlugin';
import CommandPlugin from './plugin/CommandPlugin';
import Jinja2HighlightPlugin from './plugin/Jinja2HighlightPlugin';
import LineNumberPlugin from './plugin/LineNumberPlugin';
import BlurPlugin from './plugin/BlurPlugin';
import { VariableNode } from './nodes/VariableNode'
import Jinja2Editor from './Jinja2Editor';
// Props interface for Lexical Editor component
export interface LexicalEditorProps {
@@ -39,6 +33,7 @@ export interface LexicalEditorProps {
type?: 'input' | 'textarea';
language?: 'string' | 'jinja2';
className?: string;
waitForInit?: boolean;
}
// Default theme for editor
@@ -50,16 +45,6 @@ const theme = {
},
};
// Theme with Jinja2 syntax highlighting
const jinja2Theme = {
...theme,
code: 'jinja2-expression',
text: {
...theme.text,
code: 'jinja2-inline',
},
};
// Main Lexical Editor component
const Editor: FC<LexicalEditorProps> =({
placeholder = "请输入内容...",
@@ -71,100 +56,32 @@ const Editor: FC<LexicalEditorProps> =({
type = 'textarea',
language = 'string',
height,
className
className,
waitForInit = false,
}) => {
console.log('Editor value', value)
const [_count, setCount] = useState(0);
const [enableJinja2, setEnableJinja2] = useState(false)
const [enableLineNumbers, setEnableLineNumbers] = useState(false)
// Setup Jinja2 mode and inject styles when language changes
useEffect(() => {
const needsLineNumbers = language === 'jinja2';
setEnableJinja2(language === 'jinja2');
setEnableLineNumbers(needsLineNumbers);
if (needsLineNumbers) {
const styleId = 'code-editor-styles';
let existingStyle = document.getElementById(styleId);
if (!existingStyle) {
const style = document.createElement('style');
style.id = styleId;
style.textContent = `
.jinja2-expression {
background-color: #f6f8fa !important;
border: 1px solid #d1d9e0 !important;
border-radius: 3px !important;
padding: 2px 4px !important;
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace !important;
font-size: 13px !important;
color: #0969da !important;
}
.jinja2-inline {
background-color: #f6f8fa !important;
padding: 1px 3px !important;
border-radius: 2px !important;
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace !important;
font-size: 13px !important;
color: #0969da !important;
}
.editor-paragraph {
margin: 0;
}
.editor-paragraph:has-text('{') .editor-text,
.editor-paragraph:has-text('[') .editor-text {
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace !important;
}
.editor-with-line-numbers {
display: flex;
}
.line-numbers {
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
font-size: 12px;
line-height: 16px;
padding: 4px 8px;
text-align: right;
user-select: none;
display: flex;
flex-direction: column;
}
.line-numbers > div {
min-height: 20px;
display: flex;
align-items: flex-start;
}
.editor-content-wrapper {
flex: 1;
}
.editor-content-with-numbers {
white-space: pre-wrap;
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
}
.editor-content-with-numbers p {
margin: 0;
min-height: 20px;
}
`;
document.head.appendChild(style);
}
}
}, [language])
if (language === 'jinja2') {
return (
<Jinja2Editor
placeholder={placeholder}
value={value}
onChange={onChange}
options={options}
variant={variant}
size={size}
height={height}
className={className}
/>
);
}
// Lexical editor configuration
const initialConfig = {
namespace: 'AutocompleteEditor',
theme: enableJinja2 ? jinja2Theme : theme,
nodes: enableJinja2 ? [
// When Jinja2 is enabled, use plain text instead of VariableNode
] : [
// HeadingNode,
// QuoteNode,
// ListItemNode,
// ListNode,
// LinkNode,
// CodeNode,
VariableNode,
],
theme,
nodes: [VariableNode],
onError: (error: Error) => {
console.error(error);
},
@@ -198,54 +115,26 @@ const Editor: FC<LexicalEditorProps> =({
<div style={{ position: 'relative' }} className={className}>
<RichTextPlugin
contentEditable={
enableLineNumbers ? (
// Editor with line numbers for Jinja2 mode
<div className="editor-with-line-numbers" style={{
border: variant === 'borderless' ? 'none' : '1px solid #DFE4ED',
borderRadius: '6px',
<ContentEditable
style={{
minHeight: minheight,
}}>
<div className="line-numbers">
<div>1</div>
</div>
<div className="editor-content-wrapper">
<ContentEditable
className="editor-content-with-numbers"
style={{
minHeight: minheight,
padding: variant === 'borderless' ? '0' : '4px 0',
outline: 'none',
resize: 'none',
fontSize: fontSize,
lineHeight: lineHeight,
border: 'none',
}}
/>
</div>
</div>
) : (
// Standard editor without line numbers
<ContentEditable
style={{
minHeight: minheight,
padding: height ? '4px 6px' : variant === 'borderless' ? '0' : '6px 8px',
border: variant === 'borderless' ? 'none' : '1px solid #EBEBEB',
borderRadius: '8px',
outline: 'none',
resize: 'none',
fontSize: fontSize,
lineHeight: lineHeight,
}}
/>
)
padding: height ? '4px 6px' : variant === 'borderless' ? '0' : '6px 8px',
border: variant === 'borderless' ? 'none' : '1px solid #EBEBEB',
borderRadius: '8px',
outline: 'none',
resize: 'none',
fontSize: fontSize,
lineHeight: lineHeight,
}}
/>
}
placeholder={
<div
style={{
minHeight: placeHolderMinheight,
position: 'absolute',
top: enableLineNumbers ? '4px' : variant === 'borderless' ? '0' : '6px',
left: enableLineNumbers ? '16px' : (variant === 'borderless' ? '0' : '11px'),
top: variant === 'borderless' ? '0' : '6px',
left: variant === 'borderless' ? '0' : '11px',
color: '#A8A9AA',
fontSize: fontSize,
lineHeight: placeHolderMinheight,
@@ -257,15 +146,12 @@ const Editor: FC<LexicalEditorProps> =({
}
ErrorBoundary={LexicalErrorBoundary}
/>
{/* Editor plugins */}
<HistoryPlugin />
<CommandPlugin />
{language === 'jinja2' && <Jinja2HighlightPlugin />}
{enableLineNumbers && <LineNumberPlugin />}
<AutocompletePlugin options={options} enableJinja2={enableJinja2} />
<CharacterCountPlugin setCount={(count) => { setCount(count) }} onChange={onChange} />
<InitialValuePlugin value={value} options={options} enableLineNumbers={enableLineNumbers} />
<BlurPlugin enableJinja2={enableJinja2} />
<AutocompletePlugin options={options} />
<CharacterCountPlugin setCount={(count) => { setCount(count) }} onChange={onChange} waitForInit={waitForInit || !!value} />
<InitialValuePlugin value={value} options={options} />
<BlurPlugin />
</div>
</LexicalComposer>
);

View File

@@ -2,11 +2,11 @@
* @Author: ZhaoYing
* @Date: 2025-12-23 16:22:51
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-03-25 16:13:37
* @Last Modified time: 2026-04-02 17:12:41
*/
import { useEffect, useState, useRef, type FC } from 'react';
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
import { $getSelection, $isRangeSelection, $isTextNode, COMMAND_PRIORITY_HIGH, KEY_ENTER_COMMAND, KEY_ARROW_DOWN_COMMAND, KEY_ARROW_UP_COMMAND, KEY_ESCAPE_COMMAND } from 'lexical';
import { $getSelection, $isRangeSelection, COMMAND_PRIORITY_HIGH, KEY_ENTER_COMMAND, KEY_ARROW_DOWN_COMMAND, KEY_ARROW_UP_COMMAND, KEY_ESCAPE_COMMAND } from 'lexical';
import { Space, Flex } from 'antd';
import { INSERT_VARIABLE_COMMAND, CLOSE_AUTOCOMPLETE_COMMAND } from '../commands';
@@ -26,7 +26,7 @@ export interface Suggestion {
}
// Autocomplete plugin for variable suggestions triggered by '/' character
const AutocompletePlugin: FC<{ options: Suggestion[], enableJinja2?: boolean }> = ({ options, enableJinja2 = false }) => {
const AutocompletePlugin: FC<{ options: Suggestion[] }> = ({ options }) => {
const [editor] = useLexicalComposerContext();
const [showSuggestions, setShowSuggestions] = useState(false);
const [selectedIndex, setSelectedIndex] = useState(0);
@@ -129,34 +129,7 @@ const AutocompletePlugin: FC<{ options: Suggestion[], enableJinja2?: boolean }>
// Insert selected suggestion into editor
const insertMention = (suggestion: Suggestion) => {
if (enableJinja2) {
// In Jinja2 mode, insert {{variable}} format text
editor.update(() => {
const selection = $getSelection();
if ($isRangeSelection(selection)) {
const anchorNode = selection.anchor.getNode();
const anchorOffset = selection.anchor.offset;
const nodeText = anchorNode.getTextContent();
// Remove trigger character '/'
const textBefore = nodeText.substring(0, anchorOffset - 1);
const textAfter = nodeText.substring(anchorOffset);
const newText = textBefore + `{{${suggestion.value}}}` + textAfter;
if ($isTextNode(anchorNode)) {
anchorNode.setTextContent(newText);
}
// Set cursor position after inserted text
const newOffset = textBefore.length + `{{${suggestion.value}}}`.length;
selection.anchor.offset = newOffset;
selection.focus.offset = newOffset;
}
});
} else {
// In normal mode, use VariableNode
editor.dispatchCommand(INSERT_VARIABLE_COMMAND, { data: suggestion });
}
editor.dispatchCommand(INSERT_VARIABLE_COMMAND, { data: suggestion });
setShowSuggestions(false);
};

View File

@@ -1,64 +1,33 @@
/*
* @Author: ZhaoYing
* @Date: 2026-01-20 10:42:13
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-03-03 10:12:10
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-04-02 17:13:08
*/
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
import { useEffect } from 'react';
import { $setSelection } from 'lexical';
import { CLOSE_AUTOCOMPLETE_COMMAND } from '../commands';
// Plugin to handle blur events and close autocomplete when clicking outside
export default function BlurPlugin({ enableJinja2 }: { enableJinja2: boolean }) {
export default function BlurPlugin() {
const [editor] = useLexicalComposerContext();
useEffect(() => {
// Close autocomplete when clicking outside the popup
const handleClickOutside = (e: MouseEvent) => {
const target = e.target as HTMLElement;
if (target?.closest('[data-autocomplete-popup="true"]')) {
return;
}
if ((e.target as HTMLElement)?.closest('[data-autocomplete-popup="true"]')) return;
editor.dispatchCommand(CLOSE_AUTOCOMPLETE_COMMAND, undefined);
};
document.addEventListener('mousedown', handleClickOutside);
return editor.registerRootListener((rootElement) => {
if (rootElement) {
const handleBlur = (e: FocusEvent) => {
if (enableJinja2) {
// Check if autocomplete popup was clicked
const target = e.target as HTMLElement;
if (target?.closest('[data-autocomplete-popup="true"]')) {
return;
}
// Check if blur was caused by paste operation
const relatedTarget = e.relatedTarget as HTMLElement;
if (!relatedTarget || relatedTarget === document.body) {
return;
}
// Clear selection on blur
editor.update(() => {
$setSelection(null);
});
}
};
rootElement.addEventListener('blur', handleBlur);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
rootElement.removeEventListener('blur', handleBlur);
};
}
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
return () => { document.removeEventListener('mousedown', handleClickOutside); };
});
}, [editor, enableJinja2]);
}, [editor]);
return null;
}

View File

@@ -1,43 +1,73 @@
import { useEffect } from 'react';
/*
* @Author: ZhaoYing
* @Date: 2025-12-23 16:22:51
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-04-02 17:13:45
*/
import { useEffect, useRef } from 'react';
import { $getRoot, $isParagraphNode } from 'lexical';
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
import { $isVariableNode } from '../nodes/VariableNode';
const CharacterCountPlugin = ({ setCount, onChange }: { setCount: (count: number) => void; onChange?: (value: string) => void }) => {
const serialize = (root: ReturnType<typeof $getRoot>): string => {
const paragraphs: string[] = [];
root.getChildren().forEach(child => {
if ($isParagraphNode(child)) {
let content = '';
child.getChildren().forEach(node => {
content += $isVariableNode(node) ? node.getTextContent() : node.getTextContent();
});
paragraphs.push(content);
}
});
return paragraphs.join('\n');
};
const CharacterCountPlugin = ({
setCount,
onChange,
waitForInit = false,
}: {
setCount: (count: number) => void;
onChange?: (value: string) => void;
waitForInit?: boolean;
}) => {
const [editor] = useLexicalComposerContext();
// lastProgrammaticValue tracks what InitialValuePlugin wrote, so we can
// suppress onChange when the content hasn't actually changed from that value.
const lastProgrammaticValueRef = useRef<string | null>(null);
const isReadyRef = useRef(!waitForInit);
const isFirstUpdateRef = useRef(true);
useEffect(() => {
return editor.registerUpdateListener(({ editorState }) => {
editorState.read(() => {
const root = $getRoot();
let serializedContent = '';
// Traverse all nodes and serialize properly
const paragraphs: string[] = [];
root.getChildren().forEach(child => {
if ($isParagraphNode(child)) {
let paragraphContent = '';
child.getChildren().forEach(node => {
if ($isVariableNode(node)) {
paragraphContent += node.getTextContent();
} else {
paragraphContent += node.getTextContent();
}
});
paragraphs.push(paragraphContent);
}
return editor.registerUpdateListener(({ editorState, tags }) => {
if (tags.has('programmatic')) {
isReadyRef.current = true;
isFirstUpdateRef.current = false;
editorState.read(() => {
lastProgrammaticValueRef.current = serialize($getRoot());
});
serializedContent = paragraphs.join('\n');
setCount(serializedContent.length);
onChange?.(serializedContent);
return;
}
if (!isReadyRef.current) return;
editorState.read(() => {
const content = serialize($getRoot());
// Skip the first update if content is empty (editor initial render)
if (isFirstUpdateRef.current) {
isFirstUpdateRef.current = false;
if (content === '') return;
}
// Skip if content is identical to what was programmatically written
if (content === lastProgrammaticValueRef.current) return;
lastProgrammaticValueRef.current = null;
setCount(content.length);
onChange?.(content);
});
});
}, [editor, setCount, onChange]);
return null;
}
};
export default CharacterCountPlugin
export default CharacterCountPlugin;

View File

@@ -1,3 +1,9 @@
/*
* @Author: ZhaoYing
* @Date: 2025-12-23 16:22:51
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-04-02 17:14:15
*/
import { useEffect, useRef } from 'react';
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
import { $getRoot, $createParagraphNode, $createTextNode } from 'lexical';
@@ -8,19 +14,17 @@ import { type Suggestion } from '../plugin/AutocompletePlugin'
interface InitialValuePluginProps {
value: string;
options?: Suggestion[];
enableLineNumbers?: boolean;
}
const InitialValuePlugin: React.FC<InitialValuePluginProps> = ({ value, options = [], enableLineNumbers = false }) => {
const InitialValuePlugin: React.FC<InitialValuePluginProps> = ({ value, options = [] }) => {
const [editor] = useLexicalComposerContext();
const prevValueRef = useRef<string>('');
const prevEnableLineNumbersRef = useRef<boolean>(enableLineNumbers);
const isUserInputRef = useRef(false);
const optionsRef = useRef(options);
optionsRef.current = options;
useEffect(() => {
const removeListener = editor.registerUpdateListener(({ editorState, tags }) => {
return editor.registerUpdateListener(({ editorState, tags }) => {
if (tags.has('programmatic')) return;
editorState.read(() => {
const root = $getRoot();
@@ -31,21 +35,16 @@ const InitialValuePlugin: React.FC<InitialValuePluginProps> = ({ value, options
}
});
});
return removeListener;
}, [editor]);
useEffect(() => {
if (value !== prevValueRef.current || enableLineNumbers !== prevEnableLineNumbersRef.current) {
// Skip reset if the change was triggered by user input (avoid cursor jump)
if (isUserInputRef.current && enableLineNumbers === prevEnableLineNumbersRef.current) {
if (value !== prevValueRef.current) {
if (isUserInputRef.current) {
prevValueRef.current = value;
isUserInputRef.current = false;
return;
}
// Update refs BEFORE editor.update to prevent re-entry
prevValueRef.current = value;
prevEnableLineNumbersRef.current = enableLineNumbers;
isUserInputRef.current = false;
queueMicrotask(() => {
@@ -54,16 +53,7 @@ const InitialValuePlugin: React.FC<InitialValuePluginProps> = ({ value, options
root.clear();
const parts = value.split(/(\{\{[^}]+\}\}|\n)/);
if (enableLineNumbers) {
const lines = value.split('\n');
lines.forEach((line) => {
const paragraph = $createParagraphNode();
paragraph.append($createTextNode(line));
root.append(paragraph);
});
} else {
let paragraph = $createParagraphNode();
let paragraph = $createParagraphNode();
parts.forEach(part => {
if (part === '\n') {
@@ -118,15 +108,10 @@ const InitialValuePlugin: React.FC<InitialValuePluginProps> = ({ value, options
}
});
root.append(paragraph);
}
}, { tag: 'programmatic' });
});
} else {
prevValueRef.current = value;
prevEnableLineNumbersRef.current = enableLineNumbers;
isUserInputRef.current = false;
}
}, [value, editor, enableLineNumbers]);
}, [value, editor]);
return null;
};

View File

@@ -0,0 +1,199 @@
/*
* @Author: ZhaoYing
* @Date: 2026-04-02 17:10:59
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-04-02 17:10:59
*/
import { useEffect, useState, useRef, type FC } from 'react';
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
import {
$getSelection, $isRangeSelection, $isTextNode,
COMMAND_PRIORITY_HIGH, KEY_ENTER_COMMAND, KEY_ARROW_DOWN_COMMAND,
KEY_ARROW_UP_COMMAND, KEY_ESCAPE_COMMAND,
} from 'lexical';
import { Space, Flex } from 'antd';
import { CLOSE_AUTOCOMPLETE_COMMAND } from '../commands';
import type { Suggestion } from './AutocompletePlugin';
const Jinja2AutocompletePlugin: FC<{ options: Suggestion[] }> = ({ options }) => {
const [editor] = useLexicalComposerContext();
const [showSuggestions, setShowSuggestions] = useState(false);
const [selectedIndex, setSelectedIndex] = useState(0);
const [popupPosition, setPopupPosition] = useState({ top: 0, left: 0 });
const popupRef = useRef<HTMLDivElement>(null);
const scrollSelectedIntoView = () => {
if (!popupRef.current) return;
const selectedElement = popupRef.current.querySelector('[data-selected="true"]');
if (!selectedElement) return;
const container = popupRef.current;
const element = selectedElement as HTMLElement;
const containerRect = container.getBoundingClientRect();
const elementRect = element.getBoundingClientRect();
if (elementRect.bottom > containerRect.bottom) {
container.scrollTop += elementRect.bottom - containerRect.bottom;
} else if (elementRect.top < containerRect.top) {
container.scrollTop -= containerRect.top - elementRect.top;
}
};
useEffect(() => {
return editor.registerUpdateListener(({ editorState }) => {
editorState.read(() => {
const selection = $getSelection();
if (!selection || !$isRangeSelection(selection)) {
setShowSuggestions(false);
return;
}
const anchorNode = selection.anchor.getNode();
const anchorOffset = selection.anchor.offset;
const textBeforeCursor = anchorNode.getTextContent().substring(0, anchorOffset);
const shouldShow = textBeforeCursor.endsWith('/');
setShowSuggestions(shouldShow);
if (!shouldShow) { setSelectedIndex(0); return; }
const domSelection = window.getSelection();
if (domSelection && domSelection.rangeCount > 0) {
const rect = domSelection.getRangeAt(0).getBoundingClientRect();
const popupWidth = 280, popupHeight = 200;
const vw = window.innerWidth, vh = window.innerHeight;
let left = Math.min(Math.max(rect.left, 10), vw - popupWidth - 10);
let top = rect.top - 10;
if (top - popupHeight < 10) {
top = Math.min(rect.bottom + 10, vh - popupHeight - 10);
}
setPopupPosition({ top, left });
}
});
});
}, [editor]);
useEffect(() => {
return editor.registerCommand(
CLOSE_AUTOCOMPLETE_COMMAND,
() => { setShowSuggestions(false); return true; },
COMMAND_PRIORITY_HIGH,
);
}, [editor]);
const insertMention = (suggestion: Suggestion) => {
editor.update(() => {
const selection = $getSelection();
if (!$isRangeSelection(selection)) return;
const anchorNode = selection.anchor.getNode();
const anchorOffset = selection.anchor.offset;
const nodeText = anchorNode.getTextContent();
const textBefore = nodeText.substring(0, anchorOffset - 1);
const textAfter = nodeText.substring(anchorOffset);
const inserted = `{{${suggestion.value}}}`;
if ($isTextNode(anchorNode)) {
anchorNode.setTextContent(textBefore + inserted + textAfter);
const newOffset = textBefore.length + inserted.length;
selection.anchor.offset = newOffset;
selection.focus.offset = newOffset;
}
});
setShowSuggestions(false);
};
const groupedSuggestions = options.reduce((groups: Record<string, Suggestion[]>, s) => {
const id = s.nodeData.id as string;
if (!groups[id]) groups[id] = [];
groups[id].push(s);
return groups;
}, {});
const allOptions = Object.values(groupedSuggestions).flat();
useEffect(() => {
if (!showSuggestions) return;
return editor.registerCommand(
KEY_ENTER_COMMAND,
(event) => {
const opt = allOptions[selectedIndex];
if (opt && !opt.disabled) { event?.preventDefault(); insertMention(opt); return true; }
return false;
},
COMMAND_PRIORITY_HIGH,
);
}, [showSuggestions, selectedIndex, allOptions]);
useEffect(() => {
if (!showSuggestions) return;
const down = editor.registerCommand(KEY_ARROW_DOWN_COMMAND, (e) => {
e?.preventDefault();
setSelectedIndex(prev => {
let next = prev + 1;
while (next < allOptions.length && allOptions[next].disabled) next++;
setTimeout(scrollSelectedIntoView, 0);
return next >= allOptions.length ? prev : next;
});
return true;
}, COMMAND_PRIORITY_HIGH);
const up = editor.registerCommand(KEY_ARROW_UP_COMMAND, (e) => {
e?.preventDefault();
setSelectedIndex(prev => {
let p = prev - 1;
while (p >= 0 && allOptions[p].disabled) p--;
setTimeout(scrollSelectedIntoView, 0);
return p < 0 ? prev : p;
});
return true;
}, COMMAND_PRIORITY_HIGH);
const esc = editor.registerCommand(KEY_ESCAPE_COMMAND, (e) => {
e?.preventDefault(); setShowSuggestions(false); return true;
}, COMMAND_PRIORITY_HIGH);
return () => { down(); up(); esc(); };
}, [showSuggestions, selectedIndex, allOptions, editor]);
if (!showSuggestions || Object.keys(groupedSuggestions).length === 0) return null;
return (
<div
ref={popupRef}
data-autocomplete-popup="true"
onMouseDown={(e) => e.preventDefault()}
className="rb:fixed rb:z-1000 rb:py-1 rb:bg-white rb:rounded-xl rb:min-w-70 rb:max-h-50 rb:overflow-y-auto rb:transform-[translateY(-100%)] rb:shadow-[0px_2px_12px_0px_rgba(23,23,25,0.12)]"
style={{ top: popupPosition.top, left: popupPosition.left }}
>
<Flex vertical gap={12}>
{Object.entries(groupedSuggestions).map(([nodeId, nodeOptions]) => (
<div key={nodeId}>
<Flex align="center" gap={4} className="rb:px-3! rb:text-[12px] rb:py-1.25! rb:font-medium rb:text-[#5B6167]">
{nodeOptions[0]?.nodeData?.icon && <img src={nodeOptions[0].nodeData.icon} className="rb:size-3" alt="" />}
{nodeOptions[0]?.nodeData?.name || nodeId}
</Flex>
{nodeOptions.map((option) => {
const globalIndex = allOptions.indexOf(option);
return (
<Flex
key={option.key}
data-selected={selectedIndex === globalIndex}
className="rb:pl-6! rb:pr-3! rb:py-2!"
align="center"
justify="space-between"
style={{
cursor: option.disabled ? 'not-allowed' : 'pointer',
background: selectedIndex === globalIndex ? '#f0f8ff' : 'white',
opacity: option.disabled ? 0.5 : 1,
}}
onClick={() => !option.disabled && insertMention(option)}
onMouseEnter={() => setSelectedIndex(globalIndex)}
>
<Space size={4}>
<span className="rb:text-[#155EEF]">{option.isContext ? '📄' : '{x}'}</span>
<span>{option.label}</span>
</Space>
{option.dataType && <span className="rb:text-[#5B6167]">{option.dataType}</span>}
</Flex>
);
})}
</div>
))}
</Flex>
</div>
);
};
export default Jinja2AutocompletePlugin;

View File

@@ -0,0 +1,41 @@
/*
* @Author: ZhaoYing
* @Date: 2026-04-02 17:11:04
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-04-02 17:11:04
*/
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
import { useEffect } from 'react';
import { $setSelection } from 'lexical';
import { CLOSE_AUTOCOMPLETE_COMMAND } from '../commands';
export default function Jinja2BlurPlugin() {
const [editor] = useLexicalComposerContext();
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if ((e.target as HTMLElement)?.closest('[data-autocomplete-popup="true"]')) return;
editor.dispatchCommand(CLOSE_AUTOCOMPLETE_COMMAND, undefined);
};
document.addEventListener('mousedown', handleClickOutside);
return editor.registerRootListener((rootElement) => {
if (rootElement) {
const handleBlur = (e: FocusEvent) => {
if ((e.target as HTMLElement)?.closest('[data-autocomplete-popup="true"]')) return;
const relatedTarget = e.relatedTarget as HTMLElement;
if (!relatedTarget || relatedTarget === document.body) return;
editor.update(() => { $setSelection(null); });
};
rootElement.addEventListener('blur', handleBlur);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
rootElement.removeEventListener('blur', handleBlur);
};
}
return () => { document.removeEventListener('mousedown', handleClickOutside); };
});
}, [editor]);
return null;
}

View File

@@ -0,0 +1,61 @@
/*
* @Author: ZhaoYing
* @Date: 2026-04-02 17:11:07
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-04-02 17:11:07
*/
import { useEffect, useRef } from 'react';
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
import { $getRoot, $createParagraphNode, $createTextNode } from 'lexical';
interface Jinja2InitialValuePluginProps {
value: string;
}
const Jinja2InitialValuePlugin: React.FC<Jinja2InitialValuePluginProps> = ({ value }) => {
const [editor] = useLexicalComposerContext();
const prevValueRef = useRef<string>('');
const isUserInputRef = useRef(false);
useEffect(() => {
return editor.registerUpdateListener(({ editorState, tags }) => {
if (tags.has('programmatic')) return;
editorState.read(() => {
const textContent = $getRoot().getTextContent();
if (textContent !== prevValueRef.current) {
isUserInputRef.current = true;
prevValueRef.current = textContent;
}
});
});
}, [editor]);
useEffect(() => {
if (value === prevValueRef.current) return;
if (isUserInputRef.current) {
prevValueRef.current = value;
isUserInputRef.current = false;
return;
}
prevValueRef.current = value;
isUserInputRef.current = false;
queueMicrotask(() => {
editor.update(() => {
const root = $getRoot();
root.clear();
value.split('\n').forEach((line) => {
const paragraph = $createParagraphNode();
paragraph.append($createTextNode(line));
root.append(paragraph);
});
}, { tag: 'programmatic' });
});
}, [value, editor]);
return null;
};
export default Jinja2InitialValuePlugin;

View File

@@ -2,7 +2,7 @@
* @Author: ZhaoYing
* @Date: 2026-02-09 18:35:43
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-03-20 11:32:44
* @Last Modified time: 2026-04-02 17:17:06
*/
import { type FC, useRef, useState } from "react";
import { useTranslation } from 'react-i18next'
@@ -114,6 +114,7 @@ const HttpRequest: FC<{ options: Suggestion[]; selectedNode?: any; graphRef?: an
<Col span={16}>
<Form.Item name="url">
<Editor
key="url"
options={options.filter(vo => vo.dataType === 'string' || vo.dataType === 'number')}
variant="outlined"
type="input"
@@ -212,13 +213,15 @@ const HttpRequest: FC<{ options: Suggestion[]; selectedNode?: any; graphRef?: an
}
{values?.body?.content_type === 'binary' &&
<Form.Item name={['body', 'data']}
className="rb:bg-[#F6F6F6] rb:border-[#F6F6F6]! rb:hover:bg-white rb:hover:border-[#171719]! rb:border rb:rounded-lg rb:px-2! rb:py-1.5! rb:mb-0!"
className="rb:bg-[#F6F6F6] rb:border-[#F6F6F6]! rb:hover:bg-white rb:hover:border-[#171719]! rb:border rb:rounded-lg rb:mb-0!"
>
<Editor
key={['body', 'data'].join('_')}
placeholder={t('common.pleaseSelect')}
options={options.filter(vo => vo.dataType.includes('file'))}
type="input"
size="small"
height={28}
/>
</Form.Item>
}

View File

@@ -490,32 +490,36 @@ export const useWorkflowGraph = ({
* @param node - Clicked node
*/
const nodeClick = ({ node }: { node: Node }) => {
// Ignore add-node type node clicks
const nodeData = node.getData()
if (nodeData?.type === 'add-node' || nodeData.type === 'break' || nodeData.type === 'cycle-start') {
setSelectedNode(null)
return;
}
blankClick()
const nodes = graphRef.current?.getNodes();
nodes?.forEach(vo => {
const data = vo.getData();
if (data.isSelected) {
vo.setData({
...data,
isSelected: false,
});
setTimeout(() => {
// Ignore add-node type node clicks
const nodeData = node.getData()
if (nodeData?.type === 'add-node' || nodeData.type === 'break' || nodeData.type === 'cycle-start') {
setSelectedNode(null)
return;
}
});
node.setData({
...nodeData,
isSelected: true,
});
clearEdgeSelect()
if (nodeData.type !== 'notes') {
setSelectedNode(node);
}
const nodes = graphRef.current?.getNodes();
nodes?.forEach(vo => {
const data = vo.getData();
if (data.isSelected) {
vo.setData({
...data,
isSelected: false,
});
}
});
node.setData({
...nodeData,
isSelected: true,
});
clearEdgeSelect()
if (nodeData.type !== 'notes') {
setSelectedNode(node);
}
}, 0)
};
/**
* Handle edge click event