Feat: Add a loop variable to the loop operator. #10427 (#11423)

### What problem does this PR solve?

Feat: Add a loop variable to the loop operator. #10427

### Type of change


- [x] New Feature (non-breaking change which adds functionality)
This commit is contained in:
balibabu
2025-11-21 10:11:38 +08:00
committed by GitHub
parent cc00c3ec93
commit 4c8f9f0d77
7 changed files with 345 additions and 15 deletions

View File

@ -8,4 +8,7 @@
border: 0;
background-color: transparent;
}
:global(.react-flow__node-group.selectable.selected) {
box-shadow: none;
}
}

View File

@ -8,7 +8,6 @@ import { memo } from 'react';
import { NodeHandleId, Operator } from '../../constant';
import OperatorIcon from '../../operator-icon';
import { CommonHandle, LeftEndHandle } from './handle';
import styles from './index.less';
import NodeHeader from './node-header';
import { NodeWrapper } from './node-wrapper';
import { ResizeIcon, controlStyle } from './resize-icon';
@ -23,9 +22,12 @@ export function InnerIterationNode({
return (
<ToolBar selected={selected} id={id} label={data.label} showRun={false}>
<section
className={cn('h-full bg-transparent rounded-b-md group', {
[styles.selectedHeader]: selected,
})}
className={cn(
'h-full bg-transparent rounded-b-md group border border-border-button border-t-0',
{
['border-x border-accent-primary']: selected,
},
)}
>
<NodeResizeControl style={controlStyle} minWidth={100} minHeight={50}>
<ResizeIcon />
@ -43,9 +45,9 @@ export function InnerIterationNode({
name={data.name}
label={data.label}
wrapperClassName={cn(
'bg-background-header-bar p-2 rounded-t-[10px] absolute w-full top-[-44px] left-[-0.3px]',
'bg-background-header-bar p-2 rounded-t-[10px] absolute w-full top-[-38px] left-[-0.3px] border-x border-t border-border-button',
{
[styles.selectedHeader]: selected,
['border-x border-t border-accent-primary']: selected,
},
)}
></NodeHeader>

View File

@ -0,0 +1,253 @@
import { KeyInput } from '@/components/key-input';
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 { buildOptions } from '@/utils/form';
import { Editor, loader } 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 { TypesWithArray } from '../../constant';
import { buildConversationVariableSelectOptions } from '../../utils';
import { DynamicFormHeader } from '../components/dynamic-fom-header';
import { QueryVariable } from '../components/query-variable';
loader.config({ paths: { vs: '/vs' } });
enum InputMode {
Constant = 'constant',
Variable = 'variable',
}
const InputModeOptions = buildOptions(InputMode);
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 VariableTypeOptions = buildConversationVariableSelectOptions();
const modeField = 'mode';
const ConstantValueMap = {
[TypesWithArray.Boolean]: 'yes',
[TypesWithArray.Number]: 0,
[TypesWithArray.String]: '',
[TypesWithArray.ArrayBoolean]: '[]',
[TypesWithArray.ArrayNumber]: '[]',
[TypesWithArray.ArrayString]: '[]',
[TypesWithArray.ArrayObject]: '[]',
[TypesWithArray.Object]: '{}',
};
export function DynamicVariables({
name,
label,
tooltip,
keyField = 'variable',
valueField = 'parameter',
operatorField = 'operator',
}: SelectKeysProps) {
const form = useFormContext();
const isDarkTheme = useIsDarkTheme();
const { fields, remove, append } = useFieldArray({
name: name,
control: form.control,
});
const initializeValue = useCallback(
(mode: string, variableType: string, valueFieldAlias: string) => {
if (mode === InputMode.Variable) {
form.setValue(valueFieldAlias, '', { shouldDirty: true });
} else {
const val = ConstantValueMap[variableType as TypesWithArray];
form.setValue(valueFieldAlias, val, { shouldDirty: true });
}
},
[form],
);
const handleModeChange = useCallback(
(mode: string, valueFieldAlias: string, operatorFieldAlias: string) => {
const variableType = form.getValues(operatorFieldAlias);
initializeValue(mode, variableType, valueFieldAlias);
// if (mode === InputMode.Variable) {
// form.setValue(valueFieldAlias, '');
// } else {
// const val = ConstantValueMap[variableType as TypesWithArray];
// form.setValue(valueFieldAlias, val);
// }
},
[form, initializeValue],
);
const handleVariableTypeChange = useCallback(
(variableType: string, valueFieldAlias: string, modeFieldAlias: string) => {
const mode = form.getValues(modeFieldAlias);
initializeValue(mode, variableType, valueFieldAlias);
},
[form, initializeValue],
);
const renderParameter = useCallback(
(operatorFieldName: string, modeFieldName: string) => {
const mode = form.getValues(modeFieldName);
const logicalOperator = form.getValues(operatorFieldName);
if (mode === InputMode.Constant) {
if (logicalOperator === TypesWithArray.Boolean) {
return <RadioButton></RadioButton>;
}
if (logicalOperator === TypesWithArray.Number) {
return <Input className="w-full" type="number"></Input>;
}
if (logicalOperator === TypesWithArray.String) {
return <Textarea></Textarea>;
}
return (
<Editor
height={300}
theme={isDarkTheme ? 'vs-dark' : 'vs'}
language={'json'}
options={{
minimap: { enabled: false },
automaticLayout: true,
}}
/>
);
}
return (
<QueryVariable
types={[logicalOperator]}
hideLabel
pureQuery
></QueryVariable>
);
},
[form, isDarkTheme],
);
return (
<section className="space-y-2">
<DynamicFormHeader
label={label}
tooltip={tooltip}
onClick={() =>
append({
[keyField]: '',
[valueField]: '',
[modeField]: InputMode.Constant,
[operatorField]: TypesWithArray.String,
})
}
></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}`;
const modeFieldAlias = `${name}.${index}.${modeField}`;
return (
<section key={field.id} className="flex gap-2">
<div className="flex-1 space-y-3 min-w-0">
<div className="flex items-center">
<RAGFlowFormItem name={keyFieldAlias} className="flex-1 ">
<KeyInput></KeyInput>
</RAGFlowFormItem>
<Separator className="w-2" />
<RAGFlowFormItem name={operatorFieldAlias} className="flex-1">
{(field) => (
<SelectWithSearch
value={field.value}
onChange={(val) => {
handleVariableTypeChange(
val,
valueFieldAlias,
modeFieldAlias,
);
field.onChange(val);
}}
options={VariableTypeOptions}
></SelectWithSearch>
)}
</RAGFlowFormItem>
<Separator className="w-2" />
<RAGFlowFormItem name={modeFieldAlias} className="flex-1">
{(field) => (
<SelectWithSearch
value={field.value}
onChange={(val) => {
handleModeChange(
val,
valueFieldAlias,
operatorFieldAlias,
);
field.onChange(val);
}}
options={InputModeOptions}
></SelectWithSearch>
)}
</RAGFlowFormItem>
</div>
<RAGFlowFormItem name={valueFieldAlias} className="w-full">
{renderParameter(operatorFieldAlias, modeFieldAlias)}
</RAGFlowFormItem>
</div>
<Button variant={'ghost'} onClick={() => remove(index)}>
<X />
</Button>
</section>
);
})}
</div>
</section>
);
}

View File

@ -1,4 +1,3 @@
import { FormContainer } from '@/components/form-container';
import { Form } from '@/components/ui/form';
import { zodResolver } from '@hookform/resolvers/zod';
import { memo, useMemo } from 'react';
@ -10,12 +9,21 @@ import { FormWrapper } from '../components/form-wrapper';
import { Output } from '../components/output';
import { QueryVariable } from '../components/query-variable';
import { DynamicOutput } from './dynamic-output';
import { DynamicVariables } from './dynamic-variables';
import { OutputArray } from './interface';
import { useValues } from './use-values';
import { useWatchFormChange } from './use-watch-form-change';
const FormSchema = z.object({
query: z.string().optional(),
variables: z.array(
z.object({
variable: z.string().optional(),
operator: z.string().optional(),
parameter: z.string().or(z.number()).or(z.boolean()).optional(),
mode: z.string(),
}),
),
outputs: z.array(z.object({ name: z.string(), value: z.any() })).optional(),
});
@ -41,12 +49,11 @@ function IterationForm({ node }: INextOperatorForm) {
return (
<Form {...form}>
<FormWrapper>
<FormContainer>
<QueryVariable
name="items_ref"
types={ArrayFields as any[]}
></QueryVariable>
</FormContainer>
<QueryVariable
name="items_ref"
types={ArrayFields as any[]}
></QueryVariable>
<DynamicVariables name="variables" label="Variables"></DynamicVariables>
<DynamicOutput node={node}></DynamicOutput>
<Output list={outputList}></Output>
</FormWrapper>

View File

@ -0,0 +1,59 @@
import { buildOptions } from '@/utils/form';
import { camelCase } from 'lodash';
import { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import {
JsonSchemaDataType,
VariableAssignerLogicalArrayOperator,
VariableAssignerLogicalNumberOperator,
VariableAssignerLogicalNumberOperatorLabelMap,
VariableAssignerLogicalOperator,
} from '../../constant';
export function useBuildLogicalOptions() {
const { t } = useTranslation();
const buildVariableAssignerLogicalOptions = useCallback(
(record: Record<string, any>) => {
return buildOptions(
record,
t,
'flow.variableAssignerLogicalOperatorOptions',
true,
);
},
[t],
);
const buildLogicalOptions = useCallback(
(type: string) => {
if (
type?.toLowerCase().startsWith(JsonSchemaDataType.Array.toLowerCase())
) {
return buildVariableAssignerLogicalOptions(
VariableAssignerLogicalArrayOperator,
);
}
if (type === JsonSchemaDataType.Number) {
return Object.values(VariableAssignerLogicalNumberOperator).map(
(val) => ({
label: t(
`flow.variableAssignerLogicalOperatorOptions.${camelCase(VariableAssignerLogicalNumberOperatorLabelMap[val as keyof typeof VariableAssignerLogicalNumberOperatorLabelMap] || val)}`,
),
value: val,
}),
);
}
return buildVariableAssignerLogicalOptions(
VariableAssignerLogicalOperator,
);
},
[buildVariableAssignerLogicalOptions, t],
);
return {
buildLogicalOptions,
};
}

View File

@ -1,7 +1,7 @@
import { FormFieldConfig, FormFieldType } from '@/components/dynamic-form';
import { buildSelectOptions } from '@/utils/component-util';
import { t } from 'i18next';
import { TypesWithArray } from '../constant';
import { buildConversationVariableSelectOptions } from '../utils';
export { TypesWithArray } from '../constant';
// const TypesWithoutArray = Object.values(JsonSchemaDataType).filter(
// (item) => item !== JsonSchemaDataType.Array,
@ -29,7 +29,7 @@ export const GlobalFormFields = [
placeholder: '',
required: true,
type: FormFieldType.Select,
options: buildSelectOptions(Object.values(TypesWithArray)),
options: buildConversationVariableSelectOptions(),
},
{
label: t('flow.defaultValue'),

View File

@ -7,6 +7,7 @@ import {
ICategorizeItemResult,
} from '@/interfaces/database/agent';
import { DSLComponents, RAGFlowNodeType } from '@/interfaces/database/flow';
import { buildSelectOptions } from '@/utils/component-util';
import { removeUselessFieldsFromValues } from '@/utils/form';
import { Edge, Node, XYPosition } from '@xyflow/react';
import { FormInstance, FormListFieldData } from 'antd';
@ -30,6 +31,7 @@ import {
NoDebugOperatorsList,
NodeHandleId,
Operator,
TypesWithArray,
} from './constant';
import { DataOperationsFormSchemaType } from './form/data-operations-form';
import { ExtractorFormSchemaType } from './form/extractor-form';
@ -766,3 +768,7 @@ export function buildBeginQueryWithObject(
export function getArrayElementType(type: string) {
return typeof type === 'string' ? type.match(/<([^>]+)>/)?.at(1) ?? '' : '';
}
export function buildConversationVariableSelectOptions() {
return buildSelectOptions(Object.values(TypesWithArray));
}