Feat: Add a form with data operations operators #10427 (#11001)

### What problem does this PR solve?

Feat: Add a form with data operations operators #10427

### Type of change


- [x] New Feature (non-breaking change which adds functionality)
This commit is contained in:
balibabu
2025-11-04 19:42:59 +08:00
committed by GitHub
parent 880a6a0428
commit db9fa3042b
16 changed files with 433 additions and 49 deletions

View File

@ -17,6 +17,7 @@ type RAGFlowFormItemProps = {
horizontal?: boolean;
required?: boolean;
labelClassName?: string;
className?: string;
};
export function RAGFlowFormItem({
@ -27,6 +28,7 @@ export function RAGFlowFormItem({
horizontal = false,
required = false,
labelClassName,
className,
}: RAGFlowFormItemProps) {
const form = useFormContext();
return (
@ -35,9 +37,12 @@ export function RAGFlowFormItem({
name={name}
render={({ field }) => (
<FormItem
className={cn({
'flex items-center': horizontal,
})}
className={cn(
{
'flex items-center': horizontal,
},
className,
)}
>
{label && (
<FormLabel

View File

@ -1728,6 +1728,16 @@ Important structured information may include: names, dates, locations, events, k
configuration: 'Configuration',
structuredOutput: 'Structured output',
},
operations: 'Operations',
operationsOptions: {
selectKeys: 'Select keys',
literalEval: 'Literal eval',
combine: 'Combine',
filterValues: 'Filter values',
appendOrUpdate: 'Append or update',
removeKeys: 'Remove keys',
renameKeys: 'Rename keys',
},
},
llmTools: {
bad_calculator: {

View File

@ -1609,6 +1609,16 @@ Tokenizer 会根据所选方式将内容存储为对应的数据结构。`,
configuration: '配置',
structuredOutput: '结构化输出',
},
operations: '操作',
operationsOptions: {
selectKeys: '选择键',
literalEval: '字面值求值',
combine: '合并',
filterValues: '筛选值',
appendOrUpdate: '追加或更新',
removeKeys: '删除键',
renameKeys: '重命名键',
},
},
footer: {
profile: 'All rights reserved @ React',

View File

@ -715,8 +715,23 @@ export const initialPlaceholderValues = {
// It's just a visual placeholder
};
export enum Operations {
SelectKeys = 'select keys',
LiteralEval = 'literal eval',
Combine = 'combine',
FilterValues = 'filter values',
AppendOrUpdate = 'append or update',
RemoveKeys = 'remove keys',
RenameKeys = 'rename keys',
}
export const initialDataOperationsValues = {
outputs: {},
operations: Operations.SelectKeys,
outputs: {
result: {
type: 'Array<Object>',
},
},
};
export const CategorizeAnchorPointPositions = [

View File

@ -0,0 +1,46 @@
import { BlockButton, Button } from '@/components/ui/button';
import { X } from 'lucide-react';
import { useFieldArray, useFormContext } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import { JsonSchemaDataType } from '../../constant';
import { QueryVariable } from './query-variable';
type QueryVariableListProps = {
types?: JsonSchemaDataType[];
};
export function QueryVariableList({ types }: QueryVariableListProps) {
const { t } = useTranslation();
const form = useFormContext();
const name = 'inputs';
const { fields, remove, append } = useFieldArray({
name: name,
control: form.control,
});
return (
<div className="space-y-5">
{fields.map((field, index) => {
const nameField = `${name}.${index}.input`;
return (
<div key={field.id} className="flex items-center gap-2">
<QueryVariable
name={nameField}
hideLabel
className="flex-1"
types={types}
></QueryVariable>
<Button variant={'ghost'} onClick={() => remove(index)}>
<X className="text-text-sub-title-invert " />
</Button>
</div>
);
})}
<BlockButton onClick={() => append({ input: '' })}>
{t('common.add')}
</BlockButton>
</div>
);
}

View File

@ -5,24 +5,28 @@ import {
FormLabel,
FormMessage,
} from '@/components/ui/form';
import { toLower } from 'lodash';
import { isEmpty, toLower } from 'lodash';
import { ReactNode, useMemo } from 'react';
import { useFormContext } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import { VariableType } from '../../constant';
import { JsonSchemaDataType } from '../../constant';
import { useBuildQueryVariableOptions } from '../../hooks/use-get-begin-query';
import { GroupedSelectWithSecondaryMenu } from './select-with-secondary-menu';
type QueryVariableProps = {
name?: string;
type?: VariableType;
types?: JsonSchemaDataType[];
label?: ReactNode;
hideLabel?: boolean;
className?: string;
};
export function QueryVariable({
name = 'query',
type,
types = [],
label,
hideLabel = false,
className,
}: QueryVariableProps) {
const { t } = useTranslation();
const form = useFormContext();
@ -30,23 +34,25 @@ export function QueryVariable({
const nextOptions = useBuildQueryVariableOptions();
const finalOptions = useMemo(() => {
return type
return !isEmpty(types)
? nextOptions.map((x) => {
return {
...x,
options: x.options.filter((y) => toLower(y.type).includes(type)),
options: x.options.filter((y) =>
types?.some((x) => toLower(y.type).includes(x)),
),
};
})
: nextOptions;
}, [nextOptions, type]);
}, [nextOptions, types]);
return (
<FormField
control={form.control}
name={name}
render={({ field }) => (
<FormItem>
{label || (
<FormItem className={className}>
{hideLabel || label || (
<FormLabel tooltip={t('flow.queryTip')}>
{t('flow.query')}
</FormLabel>
@ -56,7 +62,7 @@ export function QueryVariable({
options={finalOptions}
{...field}
// allowClear
type={type}
types={types}
></GroupedSelectWithSecondaryMenu>
</FormControl>
<FormMessage />

View File

@ -23,7 +23,7 @@ import { ChevronDownIcon, XIcon } from 'lucide-react';
import * as React from 'react';
import { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { VariableType } from '../../constant';
import { JsonSchemaDataType } from '../../constant';
import {
useFindAgentStructuredOutputLabel,
useShowSecondaryMenu,
@ -52,7 +52,7 @@ interface GroupedSelectWithSecondaryMenuProps {
value?: string;
onChange?: (value: string) => void;
placeholder?: string;
type?: VariableType;
types?: JsonSchemaDataType[];
}
export function GroupedSelectWithSecondaryMenu({
@ -60,7 +60,7 @@ export function GroupedSelectWithSecondaryMenu({
value,
onChange,
placeholder,
type,
types,
}: GroupedSelectWithSecondaryMenuProps) {
const { t } = useTranslation();
const [open, setOpen] = React.useState(false);
@ -157,7 +157,7 @@ export function GroupedSelectWithSecondaryMenu({
key={option.value}
data={option}
click={handleSecondaryMenuClick}
type={type}
types={types}
></StructuredOutputSecondaryMenu>
);
}

View File

@ -8,7 +8,7 @@ import { get, isEmpty, isPlainObject } from 'lodash';
import { ChevronRight } from 'lucide-react';
import { PropsWithChildren, ReactNode, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { JsonSchemaDataType, VariableType } from '../../constant';
import { JsonSchemaDataType } from '../../constant';
import { useGetStructuredOutputByValue } from '../../hooks/use-build-structured-output';
import {
hasJsonSchemaChild,
@ -20,13 +20,13 @@ type DataItem = { label: ReactNode; value: string; parentLabel?: ReactNode };
type StructuredOutputSecondaryMenuProps = {
data: DataItem;
click(option: { label: ReactNode; value: string }): void;
type?: VariableType | JsonSchemaDataType;
types?: JsonSchemaDataType[];
} & PropsWithChildren;
export function StructuredOutputSecondaryMenu({
data,
click,
type,
types = [],
}: StructuredOutputSecondaryMenuProps) {
const { t } = useTranslation();
const filterStructuredOutput = useGetStructuredOutputByValue();
@ -35,18 +35,21 @@ export function StructuredOutputSecondaryMenu({
const handleSubMenuClick = useCallback(
(option: { label: ReactNode; value: string }, dataType?: string) => () => {
// The query variable of the iteration operator can only select array type data.
if ((type && type === dataType) || !type) {
if (
(!isEmpty(types) && types?.some((x) => x === dataType)) ||
isEmpty(types)
) {
click(option);
}
},
[click, type],
[click, types],
);
const handleMenuClick = useCallback(() => {
if (isEmpty(type) || type === JsonSchemaDataType.Object) {
if (isEmpty(types) || types?.some((x) => x === JsonSchemaDataType.Object)) {
click(data);
}
}, [click, data, type]);
}, [click, data, types]);
const renderAgentStructuredOutput = useCallback(
(values: any, option: { label: ReactNode; value: string }) => {
@ -62,10 +65,10 @@ export function StructuredOutputSecondaryMenu({
const dataType = get(value, 'type');
if (
!type ||
(type &&
(dataType === type ||
hasSpecificTypeChild(value ?? {}, type)))
isEmpty(types) ||
(!isEmpty(types) &&
(types?.some((x) => x === dataType) ||
hasSpecificTypeChild(value ?? {}, types)))
) {
return (
<li key={key} className="pl-1">
@ -90,10 +93,13 @@ export function StructuredOutputSecondaryMenu({
return <div></div>;
},
[handleSubMenuClick, type],
[handleSubMenuClick, types],
);
if (!hasJsonSchemaChild(structuredOutput)) {
if (
!hasJsonSchemaChild(structuredOutput) ||
(!isEmpty(types) && !hasSpecificTypeChild(structuredOutput, types))
) {
return null;
}

View File

@ -0,0 +1,73 @@
import { SelectWithSearch } from '@/components/originui/select-with-search';
import { RAGFlowFormItem } from '@/components/ragflow-form';
import { BlockButton, Button } from '@/components/ui/button';
import { FormLabel } from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import { Separator } from '@/components/ui/separator';
import { X } from 'lucide-react';
import { ReactNode } from 'react';
import { useFieldArray, useFormContext } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
type SelectKeysProps = {
name: string;
label: ReactNode;
tooltip?: string;
keyField?: string;
valueField?: string;
operatorField?: string;
};
export function FilterValues({
name,
label,
tooltip,
keyField = 'key',
valueField = 'value',
operatorField = 'operator',
}: SelectKeysProps) {
const { t } = useTranslation();
const form = useFormContext();
const { fields, remove, append } = useFieldArray({
name: name,
control: form.control,
});
return (
<section className="space-y-2">
<FormLabel tooltip={tooltip}>{label}</FormLabel>
<div className="space-y-5">
{fields.map((field, index) => {
const keyFieldAlias = `${name}.${index}.${keyField}`;
const valueFieldAlias = `${name}.${index}.${valueField}`;
const operatorFieldAlias = `${name}.${index}.${operatorField}`;
return (
<div key={field.id} className="flex items-center gap-2">
<RAGFlowFormItem name={keyFieldAlias} className="flex-1">
<Input></Input>
</RAGFlowFormItem>
<Separator orientation="vertical" className="h-2.5" />
<RAGFlowFormItem name={operatorFieldAlias} className="flex-1">
<SelectWithSearch {...field} options={[]}></SelectWithSearch>
</RAGFlowFormItem>
<Separator orientation="vertical" className="h-2.5" />
<RAGFlowFormItem name={valueFieldAlias} className="flex-1">
<Input></Input>
</RAGFlowFormItem>
<Button variant={'ghost'} onClick={() => remove(index)}>
<X />
</Button>
</div>
);
})}
</div>
<BlockButton onClick={() => append({ [keyField]: '', [valueField]: '' })}>
{t('common.add')}
</BlockButton>
</section>
);
}

View File

@ -1,44 +1,132 @@
import { SelectWithSearch } from '@/components/originui/select-with-search';
import { RAGFlowFormItem } from '@/components/ragflow-form';
import { Form } from '@/components/ui/form';
import { Form, FormLabel } from '@/components/ui/form';
import { buildOptions } from '@/utils/form';
import { zodResolver } from '@hookform/resolvers/zod';
import { memo } from 'react';
import { useForm } from 'react-hook-form';
import { useForm, useWatch } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import { z } from 'zod';
import { initialDataOperationsValues } from '../../constant';
import {
JsonSchemaDataType,
Operations,
initialDataOperationsValues,
} from '../../constant';
import { useFormValues } from '../../hooks/use-form-values';
import { useWatchFormChange } from '../../hooks/use-watch-form-change';
import { INextOperatorForm } from '../../interface';
import { buildOutputList } from '../../utils/build-output-list';
import { FormWrapper } from '../components/form-wrapper';
import { Output } from '../components/output';
import { QueryVariableList } from '../components/query-variable-list';
import { FilterValues } from './filter-values';
import { SelectKeys } from './select-keys';
import { Updates } from './updates';
export const RetrievalPartialSchema = {
select_operation: z.string(),
inputs: z.array(z.object({ input: z.string().optional() })),
operations: z.string(),
select_keys: z.array(z.object({ name: z.string().optional() })).optional(),
remove_keys: z.array(z.object({ name: z.string().optional() })).optional(),
updates: z
.array(
z.object({ key: z.string().optional(), value: z.string().optional() }),
)
.optional(),
rename_keys: z
.array(
z.object({
old_key: z.string().optional(),
new_key: z.string().optional(),
}),
)
.optional(),
filter_values: z
.array(
z.object({
key: z.string().optional(),
value: z.string().optional(),
operator: z.string().optional(),
}),
)
.optional(),
};
export const FormSchema = z.object(RetrievalPartialSchema);
export type DataOperationsFormSchemaType = z.infer<typeof FormSchema>;
const outputList = buildOutputList(initialDataOperationsValues.outputs);
function DataOperationsForm({ node }: INextOperatorForm) {
const { t } = useTranslation();
const defaultValues = useFormValues(initialDataOperationsValues, node);
const form = useForm({
const form = useForm<DataOperationsFormSchemaType>({
defaultValues: defaultValues,
resolver: zodResolver(FormSchema),
shouldUnregister: true,
});
const operations = useWatch({ control: form.control, name: 'operations' });
const OperationsOptions = buildOptions(
Operations,
t,
`flow.operationsOptions`,
true,
);
useWatchFormChange(node?.id, form);
return (
<Form {...form}>
<FormWrapper>
<RAGFlowFormItem name="query" label={t('flow.query')}>
<SelectWithSearch options={[]} allowClear />
<div className="space-y-2">
<FormLabel tooltip={t('flow.queryTip')}>{t('flow.query')}</FormLabel>
<QueryVariableList
types={[JsonSchemaDataType.Array, JsonSchemaDataType.Object]}
></QueryVariableList>
</div>
<RAGFlowFormItem name="operations" label={t('flow.operations')}>
<SelectWithSearch options={OperationsOptions} allowClear />
</RAGFlowFormItem>
<Output list={[]}></Output>
{operations === Operations.SelectKeys && (
<SelectKeys
name="select_keys"
label={t('flow.operationsOptions.selectKeys')}
></SelectKeys>
)}
{operations === Operations.RemoveKeys && (
<SelectKeys
name="remove_keys"
label={t('flow.operationsOptions.removeKeys')}
></SelectKeys>
)}
{operations === Operations.AppendOrUpdate && (
<Updates
name="updates"
label={t('flow.operationsOptions.updates')}
keyField="key"
valueField="value"
></Updates>
)}
{operations === Operations.RenameKeys && (
<Updates
name="rename_keys"
label={t('flow.operationsOptions.renameKeys')}
keyField="old_key"
valueField="new_key"
></Updates>
)}
{operations === Operations.FilterValues && (
<FilterValues
name="filter_values"
label={t('flow.operationsOptions.filterValues')}
></FilterValues>
)}
<Output list={outputList}></Output>
</FormWrapper>
</Form>
);

View File

@ -0,0 +1,49 @@
import { RAGFlowFormItem } from '@/components/ragflow-form';
import { BlockButton, Button } from '@/components/ui/button';
import { FormLabel } from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import { X } from 'lucide-react';
import { ReactNode } from 'react';
import { useFieldArray, useFormContext } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
type SelectKeysProps = {
name: string;
label: ReactNode;
tooltip?: string;
};
export function SelectKeys({ name, label, tooltip }: SelectKeysProps) {
const { t } = useTranslation();
const form = useFormContext();
const { fields, remove, append } = useFieldArray({
name: name,
control: form.control,
});
return (
<section className="space-y-2">
<FormLabel tooltip={tooltip}>{label}</FormLabel>
<div className="space-y-5">
{fields.map((field, index) => {
const nameField = `${name}.${index}.name`;
return (
<div key={field.id} className="flex items-center gap-2">
<RAGFlowFormItem name={nameField} className="flex-1">
<Input></Input>
</RAGFlowFormItem>
<Button variant={'ghost'} onClick={() => remove(index)}>
<X />
</Button>
</div>
);
})}
</div>
<BlockButton onClick={() => append({ name: '' })}>
{t('common.add')}
</BlockButton>
</section>
);
}

View File

@ -0,0 +1,61 @@
import { RAGFlowFormItem } from '@/components/ragflow-form';
import { BlockButton, Button } from '@/components/ui/button';
import { FormLabel } from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import { X } from 'lucide-react';
import { ReactNode } from 'react';
import { useFieldArray, useFormContext } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
type SelectKeysProps = {
name: string;
label: ReactNode;
tooltip?: string;
keyField: string;
valueField: string;
};
export function Updates({
name,
label,
tooltip,
keyField,
valueField,
}: SelectKeysProps) {
const { t } = useTranslation();
const form = useFormContext();
const { fields, remove, append } = useFieldArray({
name: name,
control: form.control,
});
return (
<section className="space-y-2">
<FormLabel tooltip={tooltip}>{label}</FormLabel>
<div className="space-y-5">
{fields.map((field, index) => {
const keyFieldAlias = `${name}.${index}.${keyField}`;
const valueFieldAlias = `${name}.${index}.${valueField}`;
return (
<div key={field.id} className="flex items-center gap-2">
<RAGFlowFormItem name={keyFieldAlias} className="flex-1">
<Input></Input>
</RAGFlowFormItem>
<RAGFlowFormItem name={valueFieldAlias} className="flex-1">
<Input></Input>
</RAGFlowFormItem>
<Button variant={'ghost'} onClick={() => remove(index)}>
<X />
</Button>
</div>
);
})}
</div>
<BlockButton onClick={() => append({ [keyField]: '', [valueField]: '' })}>
{t('common.add')}
</BlockButton>
</section>
);
}

View File

@ -4,7 +4,7 @@ import { zodResolver } from '@hookform/resolvers/zod';
import { memo, useMemo } from 'react';
import { useForm, useWatch } from 'react-hook-form';
import { z } from 'zod';
import { VariableType } from '../../constant';
import { JsonSchemaDataType } from '../../constant';
import { INextOperatorForm } from '../../interface';
import { FormWrapper } from '../components/form-wrapper';
import { Output } from '../components/output';
@ -44,7 +44,7 @@ function IterationForm({ node }: INextOperatorForm) {
<FormContainer>
<QueryVariable
name="items_ref"
type={VariableType.Array}
types={[JsonSchemaDataType.Array]}
></QueryVariable>
</FormContainer>
<DynamicOutput node={node}></DynamicOutput>

View File

@ -29,6 +29,7 @@ import {
NodeHandleId,
Operator,
} from './constant';
import { DataOperationsFormSchemaType } from './form/data-operations-form';
import { ExtractorFormSchemaType } from './form/extractor-form';
import { HierarchicalMergerFormSchemaType } from './form/hierarchical-merger-form';
import { ParserFormSchemaType } from './form/parser-form';
@ -267,6 +268,15 @@ function transformExtractorParams(params: ExtractorFormSchemaType) {
return { ...params, prompts: [{ content: params.prompts, role: 'user' }] };
}
function transformDataOperationsParams(params: DataOperationsFormSchemaType) {
return {
...params,
select_keys: params?.select_keys?.map((x) => x.name),
remove_keys: params?.remove_keys?.map((x) => x.name),
inputs: params.inputs.map((x) => x.input),
};
}
// construct a dsl based on the node information of the graph
export const buildDslComponentsByGraph = (
nodes: RAGFlowNodeType[],
@ -313,6 +323,9 @@ export const buildDslComponentsByGraph = (
case Operator.Extractor:
params = transformExtractorParams(params);
break;
case Operator.DataOperations:
params = transformDataOperationsParams(params);
break;
default:
break;

View File

@ -4,14 +4,14 @@ import { JsonSchemaDataType } from '../constant';
export function hasSpecificTypeChild(
data: Record<string, any> | Array<any>,
type: string,
types: string[] = [],
) {
if (Array.isArray(data)) {
for (const value of data) {
if (isPlainObject(value) && value.type === type) {
if (isPlainObject(value) && types.some((x) => x === value.type)) {
return true;
}
if (hasSpecificTypeChild(value, type)) {
if (hasSpecificTypeChild(value, types)) {
return true;
}
}
@ -19,11 +19,11 @@ export function hasSpecificTypeChild(
if (isPlainObject(data)) {
for (const value of Object.values(data)) {
if (isPlainObject(value) && value.type === type) {
if (isPlainObject(value) && types.some((x) => x === value.type)) {
return true;
}
if (hasSpecificTypeChild(value, type)) {
if (hasSpecificTypeChild(value, types)) {
return true;
}
}
@ -33,7 +33,7 @@ export function hasSpecificTypeChild(
}
export function hasArrayChild(data: Record<string, any> | Array<any>) {
return hasSpecificTypeChild(data, JsonSchemaDataType.Array);
return hasSpecificTypeChild(data, [JsonSchemaDataType.Array]);
}
export function hasJsonSchemaChild(data: JSONSchema) {

View File

@ -1,5 +1,6 @@
import { variableEnabledFieldMap } from '@/constants/chat';
import { TFunction } from 'i18next';
import { camelCase } from 'lodash';
import omit from 'lodash/omit';
// chat model setting and generate operator
@ -32,11 +33,12 @@ export function buildOptions(
data: Record<string, any>,
t?: TFunction<['translation', ...string[]], undefined>,
prefix?: string,
camel: boolean = false,
) {
if (t) {
return Object.values(data).map((val) => ({
label: t(
`${prefix ? prefix + '.' : ''}${typeof val === 'string' ? val.toLowerCase() : val}`,
`${prefix ? prefix + '.' : ''}${typeof val === 'string' ? (camel ? camelCase(val) : val.toLowerCase()) : val}`,
),
value: val,
}));