mirror of
https://github.com/infiniflow/ragflow.git
synced 2026-01-29 22:56:36 +08:00
### What problem does this PR solve? Feat: Add VariablePickerMenuPlugin to select variables in the prompt text box by menu #4764 ### Type of change - [x] New Feature (non-breaking change which adds functionality)
This commit is contained in:
76
web/src/components/prompt-editor/index.css
Normal file
76
web/src/components/prompt-editor/index.css
Normal file
@ -0,0 +1,76 @@
|
||||
.typeahead-popover {
|
||||
background: #fff;
|
||||
box-shadow: 0px 5px 10px rgba(0, 0, 0, 0.3);
|
||||
border-radius: 8px;
|
||||
position: fixed;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.typeahead-popover ul {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
max-height: 200px;
|
||||
overflow-y: scroll;
|
||||
}
|
||||
|
||||
.typeahead-popover ul::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.typeahead-popover ul {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.typeahead-popover ul li {
|
||||
margin: 0;
|
||||
min-width: 180px;
|
||||
font-size: 14px;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.typeahead-popover ul li.selected {
|
||||
background: #eee;
|
||||
}
|
||||
|
||||
.typeahead-popover li {
|
||||
margin: 0 8px 0 8px;
|
||||
color: #050505;
|
||||
cursor: pointer;
|
||||
line-height: 16px;
|
||||
font-size: 15px;
|
||||
display: flex;
|
||||
align-content: center;
|
||||
flex-direction: row;
|
||||
flex-shrink: 0;
|
||||
background-color: #fff;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.typeahead-popover li.active {
|
||||
display: flex;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
background-size: contain;
|
||||
}
|
||||
|
||||
.typeahead-popover li .text {
|
||||
display: flex;
|
||||
line-height: 20px;
|
||||
flex-grow: 1;
|
||||
min-width: 150px;
|
||||
}
|
||||
|
||||
.typeahead-popover li .icon {
|
||||
display: flex;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
user-select: none;
|
||||
margin-right: 8px;
|
||||
line-height: 16px;
|
||||
background-size: contain;
|
||||
background-repeat: no-repeat;
|
||||
background-position: center;
|
||||
}
|
||||
83
web/src/components/prompt-editor/index.tsx
Normal file
83
web/src/components/prompt-editor/index.tsx
Normal file
@ -0,0 +1,83 @@
|
||||
import { CodeHighlightNode, CodeNode } from '@lexical/code';
|
||||
import { AutoFocusPlugin } from '@lexical/react/LexicalAutoFocusPlugin';
|
||||
import {
|
||||
InitialConfigType,
|
||||
LexicalComposer,
|
||||
} from '@lexical/react/LexicalComposer';
|
||||
import { ContentEditable } from '@lexical/react/LexicalContentEditable';
|
||||
import { LexicalErrorBoundary } from '@lexical/react/LexicalErrorBoundary';
|
||||
import { HistoryPlugin } from '@lexical/react/LexicalHistoryPlugin';
|
||||
import { OnChangePlugin } from '@lexical/react/LexicalOnChangePlugin';
|
||||
import { RichTextPlugin } from '@lexical/react/LexicalRichTextPlugin';
|
||||
import { HeadingNode, QuoteNode } from '@lexical/rich-text';
|
||||
import {
|
||||
$getRoot,
|
||||
$nodesOfType,
|
||||
EditorState,
|
||||
Klass,
|
||||
LexicalNode,
|
||||
} from 'lexical';
|
||||
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import theme from './theme';
|
||||
import { VariableNode } from './variable-node';
|
||||
import VariablePickerMenuPlugin from './variable-picker-plugin';
|
||||
|
||||
// Catch any errors that occur during Lexical updates and log them
|
||||
// or throw them as needed. If you don't throw them, Lexical will
|
||||
// try to recover gracefully without losing user data.
|
||||
function onError(error: Error) {
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
const Nodes: Array<Klass<LexicalNode>> = [
|
||||
HeadingNode,
|
||||
QuoteNode,
|
||||
CodeHighlightNode,
|
||||
CodeNode,
|
||||
VariableNode,
|
||||
];
|
||||
|
||||
type IProps = {
|
||||
value?: string;
|
||||
onChange?: (value?: string) => void;
|
||||
};
|
||||
|
||||
export function PromptEditor({ value, onChange }: IProps) {
|
||||
const { t } = useTranslation();
|
||||
const initialConfig: InitialConfigType = {
|
||||
namespace: 'PromptEditor',
|
||||
theme,
|
||||
onError,
|
||||
nodes: Nodes,
|
||||
};
|
||||
|
||||
function onValueChange(editorState: EditorState) {
|
||||
editorState?.read(() => {
|
||||
const listNodes = $nodesOfType(VariableNode); // to be removed
|
||||
// const allNodes = $dfs();
|
||||
console.log('🚀 ~ onChange ~ allNodes:', listNodes);
|
||||
|
||||
const text = $getRoot().getTextContent();
|
||||
onChange?.(text);
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<LexicalComposer initialConfig={initialConfig}>
|
||||
<RichTextPlugin
|
||||
contentEditable={
|
||||
<ContentEditable className="min-h-40 relative px-2 py-1 border" />
|
||||
}
|
||||
placeholder={
|
||||
<div className="absolute top-2 left-2">{t('common.pleaseInput')}</div>
|
||||
}
|
||||
ErrorBoundary={LexicalErrorBoundary}
|
||||
/>
|
||||
<HistoryPlugin />
|
||||
<AutoFocusPlugin />
|
||||
<VariablePickerMenuPlugin value={value}></VariablePickerMenuPlugin>
|
||||
<OnChangePlugin onChange={onValueChange}></OnChangePlugin>
|
||||
</LexicalComposer>
|
||||
);
|
||||
}
|
||||
43
web/src/components/prompt-editor/theme.ts
Normal file
43
web/src/components/prompt-editor/theme.ts
Normal file
@ -0,0 +1,43 @@
|
||||
/**
|
||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
*/
|
||||
|
||||
export default {
|
||||
code: 'editor-code',
|
||||
heading: {
|
||||
h1: 'editor-heading-h1',
|
||||
h2: 'editor-heading-h2',
|
||||
h3: 'editor-heading-h3',
|
||||
h4: 'editor-heading-h4',
|
||||
h5: 'editor-heading-h5',
|
||||
},
|
||||
image: 'editor-image',
|
||||
link: 'editor-link',
|
||||
list: {
|
||||
listitem: 'editor-listitem',
|
||||
nested: {
|
||||
listitem: 'editor-nested-listitem',
|
||||
},
|
||||
ol: 'editor-list-ol',
|
||||
ul: 'editor-list-ul',
|
||||
},
|
||||
ltr: 'ltr',
|
||||
paragraph: 'editor-paragraph',
|
||||
placeholder: 'editor-placeholder',
|
||||
quote: 'editor-quote',
|
||||
rtl: 'rtl',
|
||||
text: {
|
||||
bold: 'editor-text-bold',
|
||||
code: 'editor-text-code',
|
||||
hashtag: 'editor-text-hashtag',
|
||||
italic: 'editor-text-italic',
|
||||
overflowed: 'editor-text-overflowed',
|
||||
strikethrough: 'editor-text-strikethrough',
|
||||
underline: 'editor-text-underline',
|
||||
underlineStrikethrough: 'editor-text-underlineStrikethrough',
|
||||
},
|
||||
};
|
||||
70
web/src/components/prompt-editor/variable-node.tsx
Normal file
70
web/src/components/prompt-editor/variable-node.tsx
Normal file
@ -0,0 +1,70 @@
|
||||
import i18n from '@/locales/config';
|
||||
import { BeginId } from '@/pages/flow/constant';
|
||||
import { DecoratorNode, LexicalNode, NodeKey } from 'lexical';
|
||||
import { ReactNode } from 'react';
|
||||
const prefix = BeginId + '@';
|
||||
|
||||
export class VariableNode extends DecoratorNode<ReactNode> {
|
||||
__value: string;
|
||||
__label: string;
|
||||
|
||||
static getType(): string {
|
||||
return 'variable';
|
||||
}
|
||||
|
||||
static clone(node: VariableNode): VariableNode {
|
||||
return new VariableNode(node.__value, node.__label, node.__key);
|
||||
}
|
||||
|
||||
constructor(value: string, label: string, key?: NodeKey) {
|
||||
super(key);
|
||||
this.__value = value;
|
||||
this.__label = label;
|
||||
}
|
||||
|
||||
createDOM(): HTMLElement {
|
||||
const dom = document.createElement('span');
|
||||
dom.className = 'mr-1';
|
||||
|
||||
return dom;
|
||||
}
|
||||
|
||||
updateDOM(): false {
|
||||
return false;
|
||||
}
|
||||
|
||||
decorate(): ReactNode {
|
||||
let content: ReactNode = (
|
||||
<span className="text-blue-600">{this.__label}</span>
|
||||
);
|
||||
if (this.__value.startsWith(prefix)) {
|
||||
content = (
|
||||
<div>
|
||||
<span>{i18n.t(`flow.begin`)}</span> / {content}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="bg-gray-200 dark:bg-gray-400 text-primary inline-flex items-center rounded-md px-2 py-0">
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
getTextContent(): string {
|
||||
return `{${this.__value}}`;
|
||||
}
|
||||
}
|
||||
|
||||
export function $createVariableNode(
|
||||
value: string,
|
||||
label: string,
|
||||
): VariableNode {
|
||||
return new VariableNode(value, label);
|
||||
}
|
||||
|
||||
export function $isVariableNode(
|
||||
node: LexicalNode | null | undefined,
|
||||
): node is VariableNode {
|
||||
return node instanceof VariableNode;
|
||||
}
|
||||
251
web/src/components/prompt-editor/variable-picker-plugin.tsx
Normal file
251
web/src/components/prompt-editor/variable-picker-plugin.tsx
Normal file
@ -0,0 +1,251 @@
|
||||
/**
|
||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
*/
|
||||
|
||||
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
|
||||
import {
|
||||
LexicalTypeaheadMenuPlugin,
|
||||
MenuOption,
|
||||
useBasicTypeaheadTriggerMatch,
|
||||
} from '@lexical/react/LexicalTypeaheadMenuPlugin';
|
||||
import {
|
||||
$createParagraphNode,
|
||||
$createTextNode,
|
||||
$getRoot,
|
||||
$getSelection,
|
||||
$isRangeSelection,
|
||||
TextNode,
|
||||
} from 'lexical';
|
||||
import {
|
||||
ReactElement,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useRef,
|
||||
} from 'react';
|
||||
import * as ReactDOM from 'react-dom';
|
||||
|
||||
import { FlowFormContext } from '@/pages/flow/context';
|
||||
import { useBuildComponentIdSelectOptions } from '@/pages/flow/hooks/use-get-begin-query';
|
||||
import { $createVariableNode } from './variable-node';
|
||||
|
||||
import './index.css';
|
||||
class VariableInnerOption extends MenuOption {
|
||||
label: string;
|
||||
value: string;
|
||||
|
||||
constructor(label: string, value: string) {
|
||||
super(value);
|
||||
this.label = label;
|
||||
this.value = value;
|
||||
}
|
||||
}
|
||||
|
||||
class VariableOption extends MenuOption {
|
||||
label: ReactElement | string;
|
||||
title: string;
|
||||
options: VariableInnerOption[];
|
||||
|
||||
constructor(
|
||||
label: ReactElement | string,
|
||||
title: string,
|
||||
options: VariableInnerOption[],
|
||||
) {
|
||||
super(title);
|
||||
this.label = label;
|
||||
this.title = title;
|
||||
this.options = options;
|
||||
}
|
||||
}
|
||||
|
||||
function VariablePickerMenuItem({
|
||||
index,
|
||||
option,
|
||||
selectOptionAndCleanUp,
|
||||
}: {
|
||||
index: number;
|
||||
option: VariableOption;
|
||||
selectOptionAndCleanUp: (
|
||||
option: VariableOption | VariableInnerOption,
|
||||
) => void;
|
||||
}) {
|
||||
return (
|
||||
<li
|
||||
key={option.key}
|
||||
tabIndex={-1}
|
||||
ref={option.setRefElement}
|
||||
role="option"
|
||||
id={'typeahead-item-' + index}
|
||||
>
|
||||
<div>
|
||||
<span className="text text-slate-500">{option.title}</span>
|
||||
<ul className="pl-2 py-1">
|
||||
{option.options.map((x) => (
|
||||
<li
|
||||
key={x.value}
|
||||
onClick={() => selectOptionAndCleanUp(x)}
|
||||
className="hover:bg-slate-300 p-1"
|
||||
>
|
||||
{x.label}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
export default function VariablePickerMenuPlugin({
|
||||
value,
|
||||
}: {
|
||||
value?: string;
|
||||
}): JSX.Element {
|
||||
const [editor] = useLexicalComposerContext();
|
||||
const isFirstRender = useRef(true);
|
||||
|
||||
const node = useContext(FlowFormContext);
|
||||
|
||||
const checkForTriggerMatch = useBasicTypeaheadTriggerMatch('/', {
|
||||
minLength: 0,
|
||||
});
|
||||
|
||||
const setQueryString = useCallback(() => {}, []);
|
||||
|
||||
const options = useBuildComponentIdSelectOptions(node?.id, node?.parentId);
|
||||
|
||||
const nextOptions: VariableOption[] = options.map(
|
||||
(x) =>
|
||||
new VariableOption(
|
||||
x.label,
|
||||
x.title,
|
||||
x.options.map((y) => new VariableInnerOption(y.label, y.value)),
|
||||
),
|
||||
);
|
||||
|
||||
const findLabelByValue = useCallback(
|
||||
(value: string) => {
|
||||
const children = options.reduce<Array<{ label: string; value: string }>>(
|
||||
(pre, cur) => {
|
||||
return pre.concat(cur.options);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return children.find((x) => x.value === value)?.label;
|
||||
},
|
||||
[options],
|
||||
);
|
||||
|
||||
const onSelectOption = useCallback(
|
||||
(
|
||||
selectedOption: VariableOption | VariableInnerOption,
|
||||
nodeToRemove: TextNode | null,
|
||||
closeMenu: () => void,
|
||||
) => {
|
||||
editor.update(() => {
|
||||
const selection = $getSelection();
|
||||
|
||||
if (!$isRangeSelection(selection) || selectedOption === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (nodeToRemove) {
|
||||
nodeToRemove.remove();
|
||||
}
|
||||
|
||||
selection.insertNodes([
|
||||
$createVariableNode(
|
||||
(selectedOption as VariableInnerOption).value,
|
||||
selectedOption.label as string,
|
||||
),
|
||||
]);
|
||||
|
||||
closeMenu();
|
||||
});
|
||||
},
|
||||
[editor],
|
||||
);
|
||||
|
||||
const parseTextToVariableNodes = useCallback(
|
||||
(text: string) => {
|
||||
const paragraph = $createParagraphNode();
|
||||
|
||||
// Regular expression to match content within {}
|
||||
const regex = /{([^}]*)}/g;
|
||||
let match;
|
||||
let lastIndex = 0;
|
||||
|
||||
while ((match = regex.exec(text)) !== 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);
|
||||
}
|
||||
|
||||
// Add variable node or text node
|
||||
const label = findLabelByValue(content);
|
||||
if (label) {
|
||||
paragraph.append($createVariableNode(content, label));
|
||||
} else {
|
||||
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);
|
||||
}
|
||||
|
||||
$getRoot().clear().append(paragraph);
|
||||
},
|
||||
[findLabelByValue],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (editor && value && isFirstRender.current) {
|
||||
isFirstRender.current = false;
|
||||
editor.update(() => {
|
||||
parseTextToVariableNodes(value);
|
||||
});
|
||||
}
|
||||
}, [parseTextToVariableNodes, editor, value]);
|
||||
|
||||
return (
|
||||
<LexicalTypeaheadMenuPlugin<VariableOption | VariableInnerOption>
|
||||
onQueryChange={setQueryString}
|
||||
onSelectOption={onSelectOption}
|
||||
triggerFn={checkForTriggerMatch}
|
||||
options={nextOptions}
|
||||
menuRenderFn={(anchorElementRef, { selectOptionAndCleanUp }) =>
|
||||
anchorElementRef.current && options.length
|
||||
? ReactDOM.createPortal(
|
||||
<div className="typeahead-popover w-[200px] p-2">
|
||||
<ul>
|
||||
{nextOptions.map((option, i: number) => (
|
||||
<VariablePickerMenuItem
|
||||
index={i}
|
||||
key={option.key}
|
||||
option={option}
|
||||
selectOptionAndCleanUp={selectOptionAndCleanUp}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</div>,
|
||||
anchorElementRef.current,
|
||||
)
|
||||
: null
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user