mirror of
https://github.com/infiniflow/ragflow.git
synced 2025-12-23 06:46:40 +08:00
### What problem does this PR solve? Feat: Initialize the data pipeline canvas. #9869 ### Type of change - [x] New Feature (non-breaking change which adds functionality)
This commit is contained in:
32
web/src/pages/data-flow/form/components/api-key-field.tsx
Normal file
32
web/src/pages/data-flow/form/components/api-key-field.tsx
Normal file
@ -0,0 +1,32 @@
|
||||
import {
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@/components/ui/form';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { t } from 'i18next';
|
||||
import { useFormContext } from 'react-hook-form';
|
||||
|
||||
interface IApiKeyFieldProps {
|
||||
placeholder?: string;
|
||||
}
|
||||
export function ApiKeyField({ placeholder }: IApiKeyFieldProps) {
|
||||
const form = useFormContext();
|
||||
return (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="api_key"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('flow.apiKey')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="password" {...field} placeholder={placeholder}></Input>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,27 @@
|
||||
import {
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
} from '@/components/ui/form';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { t } from 'i18next';
|
||||
import { useFormContext } from 'react-hook-form';
|
||||
|
||||
export function DescriptionField() {
|
||||
const form = useFormContext();
|
||||
return (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`description`}
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormLabel>{t('flow.description')}</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea {...field}></Textarea>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,127 @@
|
||||
import { RAGFlowNodeType } from '@/interfaces/database/flow';
|
||||
import { MinusCircleOutlined, PlusOutlined } from '@ant-design/icons';
|
||||
import { Button, Collapse, Flex, Form, Input, Select } from 'antd';
|
||||
import { PropsWithChildren, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useBuildVariableOptions } from '../../hooks/use-get-begin-query';
|
||||
|
||||
import styles from './index.less';
|
||||
|
||||
interface IProps {
|
||||
node?: RAGFlowNodeType;
|
||||
}
|
||||
|
||||
enum VariableType {
|
||||
Reference = 'reference',
|
||||
Input = 'input',
|
||||
}
|
||||
|
||||
const getVariableName = (type: string) =>
|
||||
type === VariableType.Reference ? 'component_id' : 'value';
|
||||
|
||||
const DynamicVariableForm = ({ node }: IProps) => {
|
||||
const { t } = useTranslation();
|
||||
const valueOptions = useBuildVariableOptions(node?.id, node?.parentId);
|
||||
const form = Form.useFormInstance();
|
||||
|
||||
const options = [
|
||||
{ value: VariableType.Reference, label: t('flow.reference') },
|
||||
{ value: VariableType.Input, label: t('flow.text') },
|
||||
];
|
||||
|
||||
const handleTypeChange = useCallback(
|
||||
(name: number) => () => {
|
||||
setTimeout(() => {
|
||||
form.setFieldValue(['query', name, 'component_id'], undefined);
|
||||
form.setFieldValue(['query', name, 'value'], undefined);
|
||||
}, 0);
|
||||
},
|
||||
[form],
|
||||
);
|
||||
|
||||
return (
|
||||
<Form.List name="query">
|
||||
{(fields, { add, remove }) => (
|
||||
<>
|
||||
{fields.map(({ key, name, ...restField }) => (
|
||||
<Flex key={key} gap={10} align={'baseline'}>
|
||||
<Form.Item
|
||||
{...restField}
|
||||
name={[name, 'type']}
|
||||
className={styles.variableType}
|
||||
>
|
||||
<Select
|
||||
options={options}
|
||||
onChange={handleTypeChange(name)}
|
||||
></Select>
|
||||
</Form.Item>
|
||||
<Form.Item noStyle dependencies={[name, 'type']}>
|
||||
{({ getFieldValue }) => {
|
||||
const type = getFieldValue(['query', name, 'type']);
|
||||
return (
|
||||
<Form.Item
|
||||
{...restField}
|
||||
name={[name, getVariableName(type)]}
|
||||
className={styles.variableValue}
|
||||
>
|
||||
{type === VariableType.Reference ? (
|
||||
<Select
|
||||
placeholder={t('common.pleaseSelect')}
|
||||
options={valueOptions}
|
||||
></Select>
|
||||
) : (
|
||||
<Input placeholder={t('common.pleaseInput')} />
|
||||
)}
|
||||
</Form.Item>
|
||||
);
|
||||
}}
|
||||
</Form.Item>
|
||||
<MinusCircleOutlined onClick={() => remove(name)} />
|
||||
</Flex>
|
||||
))}
|
||||
<Form.Item>
|
||||
<Button
|
||||
type="dashed"
|
||||
onClick={() => add({ type: VariableType.Reference })}
|
||||
block
|
||||
icon={<PlusOutlined />}
|
||||
className={styles.addButton}
|
||||
>
|
||||
{t('flow.addVariable')}
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</>
|
||||
)}
|
||||
</Form.List>
|
||||
);
|
||||
};
|
||||
|
||||
export function FormCollapse({
|
||||
children,
|
||||
title,
|
||||
}: PropsWithChildren<{ title: string }>) {
|
||||
return (
|
||||
<Collapse
|
||||
className={styles.dynamicInputVariable}
|
||||
defaultActiveKey={['1']}
|
||||
items={[
|
||||
{
|
||||
key: '1',
|
||||
label: <span className={styles.title}>{title}</span>,
|
||||
children,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const DynamicInputVariable = ({ node }: IProps) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<FormCollapse title={t('flow.input')}>
|
||||
<DynamicVariableForm node={node}></DynamicVariableForm>
|
||||
</FormCollapse>
|
||||
);
|
||||
};
|
||||
|
||||
export default DynamicInputVariable;
|
||||
16
web/src/pages/data-flow/form/components/form-wrapper.tsx
Normal file
16
web/src/pages/data-flow/form/components/form-wrapper.tsx
Normal file
@ -0,0 +1,16 @@
|
||||
type FormProps = React.ComponentProps<'form'>;
|
||||
|
||||
export function FormWrapper({ children, ...props }: FormProps) {
|
||||
return (
|
||||
<form
|
||||
className="space-y-6 p-4"
|
||||
autoComplete="off"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
22
web/src/pages/data-flow/form/components/index.less
Normal file
22
web/src/pages/data-flow/form/components/index.less
Normal file
@ -0,0 +1,22 @@
|
||||
.dynamicInputVariable {
|
||||
background-color: #ebe9e950;
|
||||
:global(.ant-collapse-content) {
|
||||
background-color: #f6f6f657;
|
||||
}
|
||||
margin-bottom: 20px;
|
||||
.title {
|
||||
font-weight: 600;
|
||||
font-size: 16px;
|
||||
}
|
||||
.variableType {
|
||||
width: 30%;
|
||||
}
|
||||
.variableValue {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.addButton {
|
||||
color: rgb(22, 119, 255);
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,135 @@
|
||||
'use client';
|
||||
|
||||
import { SideDown } from '@/assets/icon/next-icon';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from '@/components/ui/collapsible';
|
||||
import {
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormMessage,
|
||||
} from '@/components/ui/form';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { RAGFlowSelect } from '@/components/ui/select';
|
||||
import { RAGFlowNodeType } from '@/interfaces/database/flow';
|
||||
import { Plus, Trash2 } from 'lucide-react';
|
||||
import { useFieldArray, useFormContext } from 'react-hook-form';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useBuildVariableOptions } from '../../hooks/use-get-begin-query';
|
||||
|
||||
interface IProps {
|
||||
node?: RAGFlowNodeType;
|
||||
}
|
||||
|
||||
enum VariableType {
|
||||
Reference = 'reference',
|
||||
Input = 'input',
|
||||
}
|
||||
|
||||
const getVariableName = (type: string) =>
|
||||
type === VariableType.Reference ? 'component_id' : 'value';
|
||||
|
||||
export function DynamicVariableForm({ node }: IProps) {
|
||||
const { t } = useTranslation();
|
||||
const form = useFormContext();
|
||||
const { fields, remove, append } = useFieldArray({
|
||||
name: 'query',
|
||||
control: form.control,
|
||||
});
|
||||
|
||||
const valueOptions = useBuildVariableOptions(node?.id, node?.parentId);
|
||||
|
||||
const options = [
|
||||
{ value: VariableType.Reference, label: t('flow.reference') },
|
||||
{ value: VariableType.Input, label: t('flow.text') },
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
{fields.map((field, index) => {
|
||||
const typeField = `query.${index}.type`;
|
||||
const typeValue = form.watch(typeField);
|
||||
return (
|
||||
<div key={field.id} className="flex items-center gap-1">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={typeField}
|
||||
render={({ field }) => (
|
||||
<FormItem className="w-2/5">
|
||||
<FormDescription />
|
||||
<FormControl>
|
||||
<RAGFlowSelect
|
||||
{...field}
|
||||
placeholder={t('common.pleaseSelect')}
|
||||
options={options}
|
||||
onChange={(val) => {
|
||||
field.onChange(val);
|
||||
form.resetField(`query.${index}.value`);
|
||||
form.resetField(`query.${index}.component_id`);
|
||||
}}
|
||||
></RAGFlowSelect>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`query.${index}.${getVariableName(typeValue)}`}
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormDescription />
|
||||
<FormControl>
|
||||
{typeValue === VariableType.Reference ? (
|
||||
<RAGFlowSelect
|
||||
placeholder={t('common.pleaseSelect')}
|
||||
{...field}
|
||||
options={valueOptions}
|
||||
></RAGFlowSelect>
|
||||
) : (
|
||||
<Input placeholder={t('common.pleaseInput')} {...field} />
|
||||
)}
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<Trash2
|
||||
className="cursor-pointer mx-3 size-4 text-colors-text-functional-danger"
|
||||
onClick={() => remove(index)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<Button onClick={append} className="mt-4" variant={'outline'} size={'sm'}>
|
||||
<Plus />
|
||||
{t('flow.addVariable')}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DynamicInputVariable({ node }: IProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Collapsible defaultOpen className="group/collapsible">
|
||||
<CollapsibleTrigger className="flex justify-between w-full pb-2">
|
||||
<span className="font-bold text-2xl text-colors-text-neutral-strong">
|
||||
{t('flow.input')}
|
||||
</span>
|
||||
<Button variant={'icon'} size={'icon'}>
|
||||
<SideDown />
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<DynamicVariableForm node={node}></DynamicVariableForm>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
35
web/src/pages/data-flow/form/components/output.tsx
Normal file
35
web/src/pages/data-flow/form/components/output.tsx
Normal file
@ -0,0 +1,35 @@
|
||||
import { t } from 'i18next';
|
||||
|
||||
export type OutputType = {
|
||||
title: string;
|
||||
type?: string;
|
||||
};
|
||||
|
||||
type OutputProps = {
|
||||
list: Array<OutputType>;
|
||||
};
|
||||
|
||||
export function transferOutputs(outputs: Record<string, any>) {
|
||||
return Object.entries(outputs).map(([key, value]) => ({
|
||||
title: key,
|
||||
type: value?.type,
|
||||
}));
|
||||
}
|
||||
|
||||
export function Output({ list }: OutputProps) {
|
||||
return (
|
||||
<section className="space-y-2">
|
||||
<div>{t('flow.output')}</div>
|
||||
<ul>
|
||||
{list.map((x, idx) => (
|
||||
<li
|
||||
key={idx}
|
||||
className="bg-background-highlight text-accent-primary rounded-sm px-2 py-1"
|
||||
>
|
||||
{x.title}: <span className="text-text-secondary">{x.type}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1 @@
|
||||
export const ProgrammaticTag = 'programmatic';
|
||||
@ -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;
|
||||
}
|
||||
181
web/src/pages/data-flow/form/components/prompt-editor/index.tsx
Normal file
181
web/src/pages/data-flow/form/components/prompt-editor/index.tsx
Normal file
@ -0,0 +1,181 @@
|
||||
import { CodeHighlightNode, CodeNode } from '@lexical/code';
|
||||
import {
|
||||
InitialConfigType,
|
||||
LexicalComposer,
|
||||
} from '@lexical/react/LexicalComposer';
|
||||
import { ContentEditable } from '@lexical/react/LexicalContentEditable';
|
||||
import { LexicalErrorBoundary } from '@lexical/react/LexicalErrorBoundary';
|
||||
import { RichTextPlugin } from '@lexical/react/LexicalRichTextPlugin';
|
||||
import { HeadingNode, QuoteNode } from '@lexical/rich-text';
|
||||
import {
|
||||
$getRoot,
|
||||
$getSelection,
|
||||
EditorState,
|
||||
Klass,
|
||||
LexicalNode,
|
||||
} from 'lexical';
|
||||
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
|
||||
import { Variable } from 'lucide-react';
|
||||
import { ReactNode, useCallback, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { PasteHandlerPlugin } from './paste-handler-plugin';
|
||||
import theme from './theme';
|
||||
import { VariableNode } from './variable-node';
|
||||
import { VariableOnChangePlugin } from './variable-on-change-plugin';
|
||||
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 PromptContentProps = { showToolbar?: boolean; multiLine?: boolean };
|
||||
|
||||
type IProps = {
|
||||
value?: string;
|
||||
onChange?: (value?: string) => void;
|
||||
placeholder?: ReactNode;
|
||||
} & PromptContentProps;
|
||||
|
||||
function PromptContent({
|
||||
showToolbar = true,
|
||||
multiLine = true,
|
||||
}: PromptContentProps) {
|
||||
const [editor] = useLexicalComposerContext();
|
||||
const [isBlur, setIsBlur] = useState(false);
|
||||
const { t } = useTranslation();
|
||||
|
||||
const insertTextAtCursor = useCallback(() => {
|
||||
editor.update(() => {
|
||||
const selection = $getSelection();
|
||||
|
||||
if (selection !== null) {
|
||||
selection.insertText(' /');
|
||||
}
|
||||
});
|
||||
}, [editor]);
|
||||
|
||||
const handleVariableIconClick = useCallback(() => {
|
||||
insertTextAtCursor();
|
||||
}, [insertTextAtCursor]);
|
||||
|
||||
const handleBlur = useCallback(() => {
|
||||
setIsBlur(true);
|
||||
}, []);
|
||||
|
||||
const handleFocus = useCallback(() => {
|
||||
setIsBlur(false);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<section
|
||||
className={cn('border rounded-sm ', { 'border-blue-400': !isBlur })}
|
||||
>
|
||||
{showToolbar && (
|
||||
<div className="border-b px-2 py-2 justify-end flex">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="inline-block cursor-pointer cursor p-0.5 hover:bg-gray-100 dark:hover:bg-slate-800 rounded-sm">
|
||||
<Variable size={16} onClick={handleVariableIconClick} />
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{t('flow.insertVariableTip')}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
<ContentEditable
|
||||
className={cn(
|
||||
'relative px-2 py-1 focus-visible:outline-none max-h-[50vh] overflow-auto',
|
||||
{
|
||||
'min-h-40': multiLine,
|
||||
},
|
||||
)}
|
||||
onBlur={handleBlur}
|
||||
onFocus={handleFocus}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function PromptEditor({
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
showToolbar,
|
||||
multiLine = true,
|
||||
}: IProps) {
|
||||
const { t } = useTranslation();
|
||||
const initialConfig: InitialConfigType = {
|
||||
namespace: 'PromptEditor',
|
||||
theme,
|
||||
onError,
|
||||
nodes: Nodes,
|
||||
};
|
||||
|
||||
const onValueChange = useCallback(
|
||||
(editorState: EditorState) => {
|
||||
editorState?.read(() => {
|
||||
// const listNodes = $nodesOfType(VariableNode); // to be removed
|
||||
// const allNodes = $dfs();
|
||||
|
||||
const text = $getRoot().getTextContent();
|
||||
|
||||
onChange?.(text);
|
||||
});
|
||||
},
|
||||
[onChange],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<LexicalComposer initialConfig={initialConfig}>
|
||||
<RichTextPlugin
|
||||
contentEditable={
|
||||
<PromptContent
|
||||
showToolbar={showToolbar}
|
||||
multiLine={multiLine}
|
||||
></PromptContent>
|
||||
}
|
||||
placeholder={
|
||||
<div
|
||||
className={cn(
|
||||
'absolute top-1 left-2 text-text-secondary pointer-events-none',
|
||||
{
|
||||
'truncate w-[90%]': !multiLine,
|
||||
'translate-y-10': multiLine,
|
||||
},
|
||||
)}
|
||||
>
|
||||
{placeholder || t('common.promptPlaceholder')}
|
||||
</div>
|
||||
}
|
||||
ErrorBoundary={LexicalErrorBoundary}
|
||||
/>
|
||||
<VariablePickerMenuPlugin value={value}></VariablePickerMenuPlugin>
|
||||
<PasteHandlerPlugin />
|
||||
<VariableOnChangePlugin
|
||||
onChange={onValueChange}
|
||||
></VariableOnChangePlugin>
|
||||
</LexicalComposer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,83 @@
|
||||
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
|
||||
import {
|
||||
$createParagraphNode,
|
||||
$createTextNode,
|
||||
$getSelection,
|
||||
$isRangeSelection,
|
||||
PASTE_COMMAND,
|
||||
} from 'lexical';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
function PasteHandlerPlugin() {
|
||||
const [editor] = useLexicalComposerContext();
|
||||
useEffect(() => {
|
||||
const removeListener = editor.registerCommand(
|
||||
PASTE_COMMAND,
|
||||
(clipboardEvent: ClipboardEvent) => {
|
||||
const clipboardData = clipboardEvent.clipboardData;
|
||||
if (!clipboardData) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const text = clipboardData.getData('text/plain');
|
||||
if (!text) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if text contains 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');
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
// If not the last line, add a line break
|
||||
if (index < lines.length - 1) {
|
||||
const lineBreak = $createTextNode('\n');
|
||||
paragraph.append(lineBreak);
|
||||
}
|
||||
});
|
||||
|
||||
// Insert paragraph
|
||||
selection.insertNodes([paragraph]);
|
||||
}
|
||||
});
|
||||
|
||||
// Prevent default paste behavior
|
||||
clipboardEvent.preventDefault();
|
||||
return true;
|
||||
}
|
||||
|
||||
// If no line breaks, use default behavior
|
||||
return false;
|
||||
},
|
||||
4,
|
||||
);
|
||||
|
||||
return () => {
|
||||
removeListener();
|
||||
};
|
||||
}, [editor]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export { PasteHandlerPlugin };
|
||||
@ -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',
|
||||
},
|
||||
};
|
||||
@ -0,0 +1,91 @@
|
||||
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;
|
||||
key?: NodeKey;
|
||||
__parentLabel?: string | ReactNode;
|
||||
__icon?: ReactNode;
|
||||
|
||||
static getType(): string {
|
||||
return 'variable';
|
||||
}
|
||||
|
||||
static clone(node: VariableNode): VariableNode {
|
||||
return new VariableNode(
|
||||
node.__value,
|
||||
node.__label,
|
||||
node.__key,
|
||||
node.__parentLabel,
|
||||
node.__icon,
|
||||
);
|
||||
}
|
||||
|
||||
constructor(
|
||||
value: string,
|
||||
label: string,
|
||||
key?: NodeKey,
|
||||
parent?: string | ReactNode,
|
||||
icon?: ReactNode,
|
||||
) {
|
||||
super(key);
|
||||
this.__value = value;
|
||||
this.__label = label;
|
||||
this.__parentLabel = parent;
|
||||
this.__icon = icon;
|
||||
}
|
||||
|
||||
createDOM(): HTMLElement {
|
||||
const dom = document.createElement('span');
|
||||
dom.className = 'mr-1';
|
||||
|
||||
return dom;
|
||||
}
|
||||
|
||||
updateDOM(): false {
|
||||
return false;
|
||||
}
|
||||
|
||||
decorate(): ReactNode {
|
||||
let content: ReactNode = (
|
||||
<div className="text-blue-600">{this.__label}</div>
|
||||
);
|
||||
if (this.__parentLabel) {
|
||||
content = (
|
||||
<div className="flex items-center gap-1 text-text-primary ">
|
||||
<div>{this.__icon}</div>
|
||||
<div>{this.__parentLabel}</div>
|
||||
<div className="text-text-disabled mr-1">/</div>
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="bg-gray-200 dark:bg-gray-400 text-sm inline-flex items-center rounded-md px-2 py-1">
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
getTextContent(): string {
|
||||
return `{${this.__value}}`;
|
||||
}
|
||||
}
|
||||
|
||||
export function $createVariableNode(
|
||||
value: string,
|
||||
label: string,
|
||||
parentLabel: string | ReactNode,
|
||||
icon?: ReactNode,
|
||||
): VariableNode {
|
||||
return new VariableNode(value, label, undefined, parentLabel, icon);
|
||||
}
|
||||
|
||||
export function $isVariableNode(
|
||||
node: LexicalNode | null | undefined,
|
||||
): node is VariableNode {
|
||||
return node instanceof VariableNode;
|
||||
}
|
||||
@ -0,0 +1,35 @@
|
||||
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
|
||||
import { EditorState, LexicalEditor } from 'lexical';
|
||||
import { useEffect } from 'react';
|
||||
import { ProgrammaticTag } from './constant';
|
||||
|
||||
interface IProps {
|
||||
onChange: (
|
||||
editorState: EditorState,
|
||||
editor?: LexicalEditor,
|
||||
tags?: Set<string>,
|
||||
) => void;
|
||||
}
|
||||
|
||||
export function VariableOnChangePlugin({ onChange }: IProps) {
|
||||
// Access the editor through the LexicalComposerContext
|
||||
const [editor] = useLexicalComposerContext();
|
||||
// Wrap our listener in useEffect to handle the teardown and avoid stale references.
|
||||
useEffect(() => {
|
||||
// most listeners return a teardown function that can be called to clean them up.
|
||||
return editor.registerUpdateListener(
|
||||
({ editorState, tags, dirtyElements }) => {
|
||||
// Check if there is a "programmatic" tag
|
||||
const isProgrammaticUpdate = tags.has(ProgrammaticTag);
|
||||
|
||||
// The onchange event is only triggered when the data is manually updated
|
||||
// Otherwise, the content will be displayed incorrectly.
|
||||
if (dirtyElements.size > 0 && !isProgrammaticUpdate) {
|
||||
onChange(editorState);
|
||||
}
|
||||
},
|
||||
);
|
||||
}, [editor, onChange]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@ -0,0 +1,297 @@
|
||||
/**
|
||||
* 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 React, {
|
||||
ReactElement,
|
||||
ReactNode,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
} from 'react';
|
||||
import * as ReactDOM from 'react-dom';
|
||||
|
||||
import { $createVariableNode } from './variable-node';
|
||||
|
||||
import { useBuildQueryVariableOptions } from '@/pages/agent/hooks/use-get-begin-query';
|
||||
import { ProgrammaticTag } from './constant';
|
||||
import './index.css';
|
||||
class VariableInnerOption extends MenuOption {
|
||||
label: string;
|
||||
value: string;
|
||||
parentLabel: string | JSX.Element;
|
||||
icon?: ReactNode;
|
||||
|
||||
constructor(
|
||||
label: string,
|
||||
value: string,
|
||||
parentLabel: string | JSX.Element,
|
||||
icon?: ReactNode,
|
||||
) {
|
||||
super(value);
|
||||
this.label = label;
|
||||
this.value = value;
|
||||
this.parentLabel = parentLabel;
|
||||
this.icon = icon;
|
||||
}
|
||||
}
|
||||
|
||||
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 checkForTriggerMatch = useBasicTypeaheadTriggerMatch('/', {
|
||||
minLength: 0,
|
||||
});
|
||||
|
||||
const [queryString, setQueryString] = React.useState<string | null>('');
|
||||
|
||||
const options = useBuildQueryVariableOptions();
|
||||
|
||||
const buildNextOptions = useCallback(() => {
|
||||
let filteredOptions = options;
|
||||
if (queryString) {
|
||||
const lowerQuery = queryString.toLowerCase();
|
||||
filteredOptions = options
|
||||
.map((x) => ({
|
||||
...x,
|
||||
options: x.options.filter(
|
||||
(y) =>
|
||||
y.label.toLowerCase().includes(lowerQuery) ||
|
||||
y.value.toLowerCase().includes(lowerQuery),
|
||||
),
|
||||
}))
|
||||
.filter((x) => x.options.length > 0);
|
||||
}
|
||||
|
||||
const nextOptions: VariableOption[] = filteredOptions.map(
|
||||
(x) =>
|
||||
new VariableOption(
|
||||
x.label,
|
||||
x.title,
|
||||
x.options.map((y) => {
|
||||
return new VariableInnerOption(y.label, y.value, x.label, y.icon);
|
||||
}),
|
||||
),
|
||||
);
|
||||
return nextOptions;
|
||||
}, [options, queryString]);
|
||||
|
||||
const findItemByValue = useCallback(
|
||||
(value: string) => {
|
||||
const children = options.reduce<
|
||||
Array<{
|
||||
label: string;
|
||||
value: string;
|
||||
parentLabel?: string | ReactNode;
|
||||
icon?: ReactNode;
|
||||
}>
|
||||
>((pre, cur) => {
|
||||
return pre.concat(cur.options);
|
||||
}, []);
|
||||
|
||||
return children.find((x) => x.value === value);
|
||||
},
|
||||
[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();
|
||||
}
|
||||
const variableNode = $createVariableNode(
|
||||
(selectedOption as VariableInnerOption).value,
|
||||
selectedOption.label as string,
|
||||
selectedOption.parentLabel as string | ReactNode,
|
||||
selectedOption.icon as ReactNode,
|
||||
);
|
||||
selection.insertNodes([variableNode]);
|
||||
|
||||
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 nodeItem = findItemByValue(content);
|
||||
|
||||
if (nodeItem) {
|
||||
paragraph.append(
|
||||
$createVariableNode(
|
||||
content,
|
||||
nodeItem.label,
|
||||
nodeItem.parentLabel,
|
||||
nodeItem.icon,
|
||||
),
|
||||
);
|
||||
} 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);
|
||||
|
||||
if ($isRangeSelection($getSelection())) {
|
||||
$getRoot().selectEnd();
|
||||
}
|
||||
},
|
||||
[findItemByValue],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (editor && value && isFirstRender.current) {
|
||||
isFirstRender.current = false;
|
||||
editor.update(
|
||||
() => {
|
||||
parseTextToVariableNodes(value);
|
||||
},
|
||||
{ tag: ProgrammaticTag },
|
||||
);
|
||||
}
|
||||
}, [parseTextToVariableNodes, editor, value]);
|
||||
|
||||
return (
|
||||
<LexicalTypeaheadMenuPlugin<VariableOption | VariableInnerOption>
|
||||
onQueryChange={setQueryString}
|
||||
onSelectOption={onSelectOption}
|
||||
triggerFn={checkForTriggerMatch}
|
||||
options={buildNextOptions()}
|
||||
menuRenderFn={(anchorElementRef, { selectOptionAndCleanUp }) => {
|
||||
const nextOptions = buildNextOptions();
|
||||
return anchorElementRef.current && nextOptions.length
|
||||
? ReactDOM.createPortal(
|
||||
<div className="typeahead-popover w-[200px] p-2">
|
||||
<ul className="overflow-y-auto !scrollbar-thin overflow-x-hidden">
|
||||
{nextOptions.map((option, i: number) => (
|
||||
<VariablePickerMenuItem
|
||||
index={i}
|
||||
key={option.key}
|
||||
option={option}
|
||||
selectOptionAndCleanUp={selectOptionAndCleanUp}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</div>,
|
||||
anchorElementRef.current,
|
||||
)
|
||||
: null;
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
66
web/src/pages/data-flow/form/components/query-variable.tsx
Normal file
66
web/src/pages/data-flow/form/components/query-variable.tsx
Normal file
@ -0,0 +1,66 @@
|
||||
import { SelectWithSearch } from '@/components/originui/select-with-search';
|
||||
import {
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@/components/ui/form';
|
||||
import { toLower } from 'lodash';
|
||||
import { ReactNode, useMemo } from 'react';
|
||||
import { useFormContext } from 'react-hook-form';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { VariableType } from '../../constant';
|
||||
import { useBuildQueryVariableOptions } from '../../hooks/use-get-begin-query';
|
||||
|
||||
type QueryVariableProps = {
|
||||
name?: string;
|
||||
type?: VariableType;
|
||||
label?: ReactNode;
|
||||
};
|
||||
|
||||
export function QueryVariable({
|
||||
name = 'query',
|
||||
type,
|
||||
label,
|
||||
}: QueryVariableProps) {
|
||||
const { t } = useTranslation();
|
||||
const form = useFormContext();
|
||||
|
||||
const nextOptions = useBuildQueryVariableOptions();
|
||||
|
||||
const finalOptions = useMemo(() => {
|
||||
return type
|
||||
? nextOptions.map((x) => {
|
||||
return {
|
||||
...x,
|
||||
options: x.options.filter((y) => toLower(y.type).includes(type)),
|
||||
};
|
||||
})
|
||||
: nextOptions;
|
||||
}, [nextOptions, type]);
|
||||
|
||||
return (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={name}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
{label || (
|
||||
<FormLabel tooltip={t('flow.queryTip')}>
|
||||
{t('flow.query')}
|
||||
</FormLabel>
|
||||
)}
|
||||
<FormControl>
|
||||
<SelectWithSearch
|
||||
options={finalOptions}
|
||||
{...field}
|
||||
allowClear
|
||||
></SelectWithSearch>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user