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

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(
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(),
...OutputSchema,
export const VariableAssignerSchema = {
variables: z.array(
z.object({
variable: z.string().optional(),
operator: z.string().optional(),
parameter: z.string().or(z.number()).or(z.boolean()).optional(),
}),
),
};
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,
};
}