Feat: Construct a dynamic variable assignment form #10427 (#11316)

### What problem does this PR solve?

Feat: Construct a dynamic variable assignment form #10427

### Type of change


- [x] New Feature (non-breaking change which adds functionality)
This commit is contained in:
balibabu
2025-11-17 19:45:58 +08:00
committed by GitHub
parent 7264fb6978
commit d8f413a885
14 changed files with 1275 additions and 313 deletions

1077
web/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -119,6 +119,7 @@
"zustand": "^4.5.2"
},
"devDependencies": {
"@hookform/devtools": "^4.4.0",
"@react-dev-inspector/umi4-plugin": "^2.0.1",
"@redux-devtools/extension": "^3.3.0",
"@storybook/addon-docs": "^9.1.4",

View File

@ -13,7 +13,7 @@ export const LogicalOperatorIcon = function OperatorIcon({
<IconFont
name={icon}
className={cn('size-4', {
'rotate-180': value === '>',
'rotate-180': value === ComparisonOperator.GreatThan,
})}
></IconFont>
);

View File

@ -79,8 +79,9 @@ export function AccordionOperators({
Operator.Code,
Operator.StringTransform,
Operator.DataOperations,
Operator.VariableAssigner,
Operator.ListOperations,
// Operator.VariableAssigner,
Operator.VariableAssigner,
Operator.VariableAggregator,
]}
isCustomDropdown={isCustomDropdown}

View File

@ -55,8 +55,8 @@ function InnerRetrievalNode({
<div className="flex items-center gap-1.5">
<RAGFlowAvatar
className="size-6 rounded-lg"
avatar={id}
name={item?.name || (label as string) || 'CN'}
avatar={item?.avatar}
name={item ? item?.name : id}
/>
<div className={'truncate flex-1'}>{label || item?.name}</div>

View File

@ -847,6 +847,31 @@ export enum JsonSchemaDataType {
Object = 'object',
}
export enum VariableAssignerLogicalOperator {
Overwrite = 'overwrite',
Clear = 'clear',
Set = 'set',
}
export enum VariableAssignerLogicalNumberOperator {
Overwrite = VariableAssignerLogicalOperator.Overwrite,
Clear = VariableAssignerLogicalOperator.Clear,
Set = VariableAssignerLogicalOperator.Set,
Add = '+=',
Subtract = '-=',
Multiply = '*=',
Divide = '/=',
}
export enum VariableAssignerLogicalArrayOperator {
Overwrite = VariableAssignerLogicalOperator.Overwrite,
Clear = VariableAssignerLogicalOperator.Clear,
Append = 'append',
Extend = 'extend',
RemoveFirst = 'remove_first',
RemoveLast = 'remove_last',
}
export enum ExportFileType {
PDF = 'pdf',
HTML = 'html',

View File

@ -19,6 +19,8 @@ type QueryVariableProps = {
hideLabel?: boolean;
className?: string;
onChange?: (value: string) => void;
pureQuery?: boolean;
value?: string;
};
export function QueryVariable({
@ -28,12 +30,34 @@ export function QueryVariable({
hideLabel = false,
className,
onChange,
pureQuery = false,
value,
}: QueryVariableProps) {
const { t } = useTranslation();
const form = useFormContext();
const finalOptions = useFilterQueryVariableOptionsByTypes(types);
const renderWidget = (
value?: string,
handleChange?: (value: string) => void,
) => (
<GroupedSelectWithSecondaryMenu
options={finalOptions}
value={value}
onChange={(val) => {
handleChange?.(val);
onChange?.(val);
}}
// allowClear
types={types}
></GroupedSelectWithSecondaryMenu>
);
if (pureQuery) {
renderWidget(value, onChange);
}
return (
<FormField
control={form.control}
@ -45,18 +69,7 @@ export function QueryVariable({
{t('flow.query')}
</FormLabel>
)}
<FormControl>
<GroupedSelectWithSecondaryMenu
options={finalOptions}
value={field.value}
onChange={(val) => {
field.onChange(val);
onChange?.(val);
}}
// allowClear
types={types}
></GroupedSelectWithSecondaryMenu>
</FormControl>
<FormControl>{renderWidget(field.value, field.onChange)}</FormControl>
<FormMessage />
</FormItem>
)}

View File

@ -10,10 +10,7 @@ import { PropsWithChildren, ReactNode, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { JsonSchemaDataType } from '../../constant';
import { useGetStructuredOutputByValue } from '../../hooks/use-build-structured-output';
import {
hasJsonSchemaChild,
hasSpecificTypeChild,
} from '../../utils/filter-agent-structured-output';
import { hasSpecificTypeChild } from '../../utils/filter-agent-structured-output';
type DataItem = { label: ReactNode; value: string; parentLabel?: ReactNode };
@ -101,8 +98,9 @@ export function StructuredOutputSecondaryMenu({
);
if (
!hasJsonSchemaChild(structuredOutput) ||
(!isEmpty(types) && !hasSpecificTypeChild(structuredOutput, types))
!isEmpty(types) &&
!hasSpecificTypeChild(structuredOutput, types) &&
!types.some((x) => x === JsonSchemaDataType.Object)
) {
return null;
}

View File

@ -0,0 +1,290 @@
import { SelectWithSearch } from '@/components/originui/select-with-search';
import { RAGFlowFormItem } from '@/components/ragflow-form';
import { useIsDarkTheme } from '@/components/theme-provider';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import { Separator } from '@/components/ui/separator';
import { Textarea } from '@/components/ui/textarea';
import { Editor } from '@monaco-editor/react';
import * as RadioGroupPrimitive from '@radix-ui/react-radio-group';
import { X } from 'lucide-react';
import { ReactNode, useCallback } from 'react';
import { useFieldArray, useFormContext } from 'react-hook-form';
import {
JsonSchemaDataType,
VariableAssignerLogicalArrayOperator,
VariableAssignerLogicalNumberOperator,
VariableAssignerLogicalOperator,
} from '../../constant';
import { useGetVariableLabelOrTypeByValue } from '../../hooks/use-get-begin-query';
import { DynamicFormHeader } from '../components/dynamic-fom-header';
import { QueryVariable } from '../components/query-variable';
import { useBuildLogicalOptions } from './use-build-logical-options';
type SelectKeysProps = {
name: string;
label: ReactNode;
tooltip?: string;
keyField?: string;
valueField?: string;
operatorField?: string;
};
type RadioGroupProps = React.ComponentProps<typeof RadioGroupPrimitive.Root>;
type RadioButtonProps = Partial<
Omit<RadioGroupProps, 'onValueChange'> & {
onChange: RadioGroupProps['onValueChange'];
}
>;
function RadioButton({ value, onChange }: RadioButtonProps) {
return (
<RadioGroup
defaultValue="yes"
className="flex"
value={value}
onValueChange={onChange}
>
<div className="flex items-center gap-3">
<RadioGroupItem value="yes" id="r1" />
<Label htmlFor="r1">Yes</Label>
</div>
<div className="flex items-center gap-3">
<RadioGroupItem value="no" id="r2" />
<Label htmlFor="r2">No</Label>
</div>
</RadioGroup>
);
}
const EmptyFields = [
VariableAssignerLogicalOperator.Clear,
VariableAssignerLogicalArrayOperator.RemoveFirst,
VariableAssignerLogicalArrayOperator.RemoveLast,
];
const EmptyValueMap = {
[JsonSchemaDataType.String]: '',
[JsonSchemaDataType.Number]: 0,
[JsonSchemaDataType.Boolean]: 'yes',
[JsonSchemaDataType.Object]: {},
[JsonSchemaDataType.Array]: [],
};
export function DynamicVariables({
name,
label,
tooltip,
keyField = 'variable',
valueField = 'parameter',
operatorField = 'operator',
}: SelectKeysProps) {
const form = useFormContext();
const { getType } = useGetVariableLabelOrTypeByValue();
const isDarkTheme = useIsDarkTheme();
const { fields, remove, append, update } = useFieldArray({
name: name,
control: form.control,
});
const { buildLogicalOptions } = useBuildLogicalOptions();
const getVariableType = useCallback(
(keyFieldName: string) => {
const key = form.getValues(keyFieldName);
return getType(key);
},
[form, getType],
);
const renderParameter = useCallback(
(
keyFieldName: string,
operatorFieldName: string,
valueFieldAlias: string,
) => {
console.log(
'🚀 ~ DynamicVariables ~ valueFieldAlias:',
form.getValues(valueFieldAlias),
);
const logicalOperator = form.getValues(operatorFieldName);
const type = getVariableType(keyFieldName);
if (EmptyFields.includes(logicalOperator)) {
return null;
} else if (
logicalOperator === VariableAssignerLogicalOperator.Overwrite ||
VariableAssignerLogicalArrayOperator.Extend === logicalOperator
) {
return (
<QueryVariable types={[type]} hideLabel pureQuery></QueryVariable>
);
} else if (logicalOperator === VariableAssignerLogicalOperator.Set) {
if (type === JsonSchemaDataType.Boolean) {
return <RadioButton></RadioButton>;
}
if (type === JsonSchemaDataType.Number) {
return <Input className="w-full" type="number"></Input>;
}
if (type === JsonSchemaDataType.Object) {
return (
<Editor
height={300}
theme={isDarkTheme ? 'vs-dark' : 'vs'}
language={'json'}
options={{
minimap: { enabled: false },
automaticLayout: true,
}}
/>
);
}
if (type === JsonSchemaDataType.String) {
return <Textarea></Textarea>;
}
} else if (
Object.values(VariableAssignerLogicalNumberOperator).some(
(x) => logicalOperator === x,
)
) {
return <Input className="w-full" type="number"></Input>;
} else if (
logicalOperator === VariableAssignerLogicalArrayOperator.Append
) {
const subType = type.match(/<([^>]+)>/).at(1);
return (
<QueryVariable types={[subType]} hideLabel pureQuery></QueryVariable>
);
}
},
[form, getVariableType, isDarkTheme],
);
const handleVariableChange = useCallback(
(operatorFieldAlias: string, valueFieldAlias: string) => {
console.log(
'🚀 ~ DynamicVariables ~ operatorFieldAlias:',
operatorFieldAlias,
);
return () => {
form.setValue(
operatorFieldAlias,
VariableAssignerLogicalOperator.Overwrite,
{ shouldDirty: true, shouldValidate: true },
);
form.setValue(valueFieldAlias, '', {
shouldDirty: true,
shouldValidate: true,
});
};
},
[form],
);
const handleOperatorChange = useCallback(
(
valueFieldAlias: string,
keyFieldAlias: string,
value: string,
index: number,
) => {
const type = getVariableType(keyFieldAlias);
console.log('🚀 ~ DynamicVariables ~ type:', type);
let parameter = EmptyValueMap[type as keyof typeof EmptyValueMap];
if (value === VariableAssignerLogicalOperator.Overwrite) {
parameter = '';
}
if (value !== VariableAssignerLogicalOperator.Clear) {
form.setValue(valueFieldAlias, parameter, {
shouldDirty: true,
shouldValidate: true,
});
// form.trigger(valueFieldAlias);
// update(index, { [valueField]: parameter });
}
},
[form, getVariableType],
);
return (
<section className="space-y-2">
<DynamicFormHeader
label={label}
tooltip={tooltip}
onClick={() => append({ [keyField]: '', [valueField]: '' })}
></DynamicFormHeader>
<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 (
<section key={field.id} className="flex gap-2">
<div className="flex-1 space-y-3 min-w-0">
<div className="flex items-center">
<QueryVariable
name={keyFieldAlias}
hideLabel
className="flex-1 min-w-0"
onChange={handleVariableChange(
operatorFieldAlias,
valueFieldAlias,
)}
></QueryVariable>
<Separator className="w-2" />
<RAGFlowFormItem name={operatorFieldAlias} className="w-1/3">
{({ onChange, value }) => (
<SelectWithSearch
value={value}
onChange={(val) => {
handleOperatorChange(
valueFieldAlias,
keyFieldAlias,
val,
index,
);
onChange(val);
}}
options={buildLogicalOptions(
getVariableType(keyFieldAlias),
)}
></SelectWithSearch>
)}
</RAGFlowFormItem>
</div>
<RAGFlowFormItem name={valueFieldAlias} className="w-full">
{renderParameter(
keyFieldAlias,
operatorFieldAlias,
valueFieldAlias,
)}
</RAGFlowFormItem>
</div>
<Button variant={'ghost'} onClick={() => remove(index)}>
<X />
</Button>
</section>
);
})}
</div>
</section>
);
}

View File

@ -1,98 +1,51 @@
import { SelectWithSearch } from '@/components/originui/select-with-search';
import { RAGFlowFormItem } from '@/components/ragflow-form';
import { Form } from '@/components/ui/form';
import { Separator } from '@/components/ui/separator';
import { buildOptions } from '@/utils/form';
import { zodResolver } from '@hookform/resolvers/zod';
import { memo } from 'react';
import { useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import { z } from 'zod';
import {
JsonSchemaDataType,
Operations,
initialDataOperationsValues,
} from '../../constant';
import { 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, OutputSchema } from '../components/output';
import { QueryVariableList } from '../components/query-variable-list';
import { DynamicVariables } from './dynamic-variables';
export const RetrievalPartialSchema = {
query: 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(
export const VariableAssignerSchema = {
variables: 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(),
variable: z.string().optional(),
operator: z.string().optional(),
parameter: z.string().or(z.number()).or(z.boolean()).optional(),
}),
)
.optional(),
...OutputSchema,
),
};
export const FormSchema = z.object(RetrievalPartialSchema);
export const FormSchema = z.object(VariableAssignerSchema);
export type DataOperationsFormSchemaType = z.infer<typeof FormSchema>;
export type VariableAssignerFormSchemaType = z.infer<typeof FormSchema>;
const outputList = buildOutputList(initialDataOperationsValues.outputs);
// const outputList = buildOutputList(initialVariableAssignerValues.outputs);
function VariableAssignerForm({ node }: INextOperatorForm) {
const { t } = useTranslation();
const defaultValues = useFormValues(initialDataOperationsValues, node);
const form = useForm<DataOperationsFormSchemaType>({
const form = useForm<VariableAssignerFormSchemaType>({
defaultValues: defaultValues,
mode: 'onChange',
resolver: zodResolver(FormSchema),
shouldUnregister: true,
});
const OperationsOptions = buildOptions(
Operations,
t,
`flow.operationsOptions`,
true,
);
useWatchFormChange(node?.id, form, true);
return (
<Form {...form}>
<FormWrapper>
<QueryVariableList
tooltip={t('flow.queryTip')}
label={t('flow.query')}
types={[JsonSchemaDataType.Array, JsonSchemaDataType.Object]}
></QueryVariableList>
<Separator />
<RAGFlowFormItem name="operations" label={t('flow.operations')}>
<SelectWithSearch options={OperationsOptions} allowClear />
</RAGFlowFormItem>
<Output list={outputList} isFormRequired></Output>
<DynamicVariables name="variables" label="Variables"></DynamicVariables>
{/* <Output list={outputList} isFormRequired></Output> */}
</FormWrapper>
{/* <DevTool control={form.control} placement="top-left" /> */}
{/* set up the dev tool */}
</Form>
);
}

View File

@ -0,0 +1,28 @@
import { buildOptions } from '@/utils/form';
import { useCallback } from 'react';
import {
JsonSchemaDataType,
VariableAssignerLogicalArrayOperator,
VariableAssignerLogicalNumberOperator,
VariableAssignerLogicalOperator,
} from '../../constant';
export function useBuildLogicalOptions() {
const buildLogicalOptions = useCallback((type: string) => {
if (
type?.toLowerCase().startsWith(JsonSchemaDataType.Array.toLowerCase())
) {
return buildOptions(VariableAssignerLogicalArrayOperator);
}
if (type === JsonSchemaDataType.Number) {
return buildOptions(VariableAssignerLogicalNumberOperator);
}
return buildOptions(VariableAssignerLogicalOperator);
}, []);
return {
buildLogicalOptions,
};
}

View File

@ -7,8 +7,11 @@ import {
} from '../constant';
import useGraphStore from '../store';
function splitValue(value?: string) {
return typeof value === 'string' ? value?.split('@') : [];
}
function getNodeId(value: string) {
return value.split('@').at(0);
return splitValue(value).at(0);
}
export function useShowSecondaryMenu() {
@ -63,7 +66,7 @@ export function useFindAgentStructuredOutputLabel() {
}>,
) => {
// agent structured output
const fields = value.split('@');
const fields = splitValue(value);
if (
getOperatorTypeFromId(fields.at(0)) === Operator.Agent &&
fields.at(1)?.startsWith(AgentStructuredOutputField)
@ -130,7 +133,7 @@ export function useFindAgentStructuredOutputTypeByValue() {
if (!value) {
return;
}
const fields = value.split('@');
const fields = splitValue(value);
const nodeId = fields.at(0);
const jsonSchema = filterStructuredOutput(value);
@ -163,7 +166,7 @@ export function useFindAgentStructuredOutputLabelByValue() {
const operatorName = getNode(getNodeId(value ?? ''))?.data.name;
if (operatorName) {
return operatorName + ' / ' + value?.split('@').at(1);
return operatorName + ' / ' + splitValue(value).at(1);
}
}

View File

@ -232,8 +232,11 @@ export function useFilterQueryVariableOptionsByTypes(
...x,
options: x.options.filter(
(y) =>
types?.some((x) => toLower(y.type).includes(x)) ||
y.type === undefined, // agent structured output
types?.some((x) =>
toLower(x).startsWith('array')
? toLower(y.type).includes(toLower(x))
: toLower(y.type) === toLower(x),
) || y.type === undefined, // agent structured output
),
};
})

View File

@ -1,14 +1,18 @@
import { JSONSchema } from '@/components/jsonjoy-builder';
import { get, isPlainObject } from 'lodash';
import { get, isPlainObject, toLower } from 'lodash';
import { JsonSchemaDataType } from '../constant';
function predicate(types: string[], type: string) {
return types.some((x) => toLower(x) === toLower(type));
}
export function hasSpecificTypeChild(
data: Record<string, any> | Array<any>,
types: string[] = [],
) {
if (Array.isArray(data)) {
for (const value of data) {
if (isPlainObject(value) && types.some((x) => x === value.type)) {
if (isPlainObject(value) && predicate(types, value.type)) {
return true;
}
if (hasSpecificTypeChild(value, types)) {
@ -19,7 +23,7 @@ export function hasSpecificTypeChild(
if (isPlainObject(data)) {
for (const value of Object.values(data)) {
if (isPlainObject(value) && types.some((x) => x === value.type)) {
if (isPlainObject(value) && predicate(types, value.type)) {
return true;
}