From f33a0bd735bea743fdcc46648604762aa8c90a7c Mon Sep 17 00:00:00 2001 From: bill Date: Mon, 24 Nov 2025 18:00:31 +0800 Subject: [PATCH] Feat: Add a loop exception condition component. --- web/src/components/logical-operator.tsx | 24 ++ .../metadata-filter-conditions.tsx | 27 +- web/src/pages/agent/constant/index.tsx | 81 +++++ .../form/loop-form/dynamic-variables.tsx | 21 +- web/src/pages/agent/form/loop-form/index.tsx | 18 +- .../loop-form/loop-termination-condition.tsx | 333 ++++++++++++++++++ .../loop-form/use-build-logical-options.ts | 18 + web/src/pages/agent/utils.ts | 5 +- 8 files changed, 488 insertions(+), 39 deletions(-) create mode 100644 web/src/components/logical-operator.tsx create mode 100644 web/src/pages/agent/form/loop-form/loop-termination-condition.tsx create mode 100644 web/src/pages/agent/form/loop-form/use-build-logical-options.ts diff --git a/web/src/components/logical-operator.tsx b/web/src/components/logical-operator.tsx new file mode 100644 index 000000000..7b37a2567 --- /dev/null +++ b/web/src/components/logical-operator.tsx @@ -0,0 +1,24 @@ +import { useBuildSwitchLogicOperatorOptions } from '@/hooks/logic-hooks/use-build-options'; +import { RAGFlowFormItem } from './ragflow-form'; +import { RAGFlowSelect } from './ui/select'; + +type LogicalOperatorProps = { name: string }; + +export function LogicalOperator({ name }: LogicalOperatorProps) { + const switchLogicOperatorOptions = useBuildSwitchLogicOperatorOptions(); + + return ( +
+ + + +
+
+ ); +} diff --git a/web/src/components/metadata-filter/metadata-filter-conditions.tsx b/web/src/components/metadata-filter/metadata-filter-conditions.tsx index aee103a1f..599a6ed80 100644 --- a/web/src/components/metadata-filter/metadata-filter-conditions.tsx +++ b/web/src/components/metadata-filter/metadata-filter-conditions.tsx @@ -17,15 +17,13 @@ import { Input } from '@/components/ui/input'; import { Separator } from '@/components/ui/separator'; import { SwitchLogicOperator, SwitchOperatorOptions } from '@/constants/agent'; import { useBuildSwitchOperatorOptions } from '@/hooks/logic-hooks/use-build-operator-options'; -import { useBuildSwitchLogicOperatorOptions } from '@/hooks/logic-hooks/use-build-options'; import { useFetchKnowledgeMetadata } from '@/hooks/use-knowledge-request'; import { PromptEditor } from '@/pages/agent/form/components/prompt-editor'; import { Plus, X } from 'lucide-react'; import { useCallback } from 'react'; import { useFieldArray, useFormContext } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; -import { RAGFlowFormItem } from '../ragflow-form'; -import { RAGFlowSelect } from '../ui/select'; +import { LogicalOperator } from '../logical-operator'; export function MetadataFilterConditions({ kbIds, @@ -44,8 +42,6 @@ export function MetadataFilterConditions({ const switchOperatorOptions = useBuildSwitchOperatorOptions(); - const switchLogicOperatorOptions = useBuildSwitchLogicOperatorOptions(); - const { fields, remove, append } = useFieldArray({ name, control: form.control, @@ -53,14 +49,16 @@ export function MetadataFilterConditions({ const add = useCallback( (key: string) => () => { - form.setValue(logic, SwitchLogicOperator.And); + if (fields.length === 1) { + form.setValue(logic, SwitchLogicOperator.And); + } append({ key, value: '', op: SwitchOperatorOptions[0].value, }); }, - [append, form, logic], + [append, fields.length, form, logic], ); return ( @@ -85,20 +83,7 @@ export function MetadataFilterConditions({
- {fields.length > 1 && ( -
- - - -
-
- )} + {fields.length > 1 && }
{fields.map((field, index) => { const typeField = `${name}.${index}.key`; diff --git a/web/src/pages/agent/constant/index.tsx b/web/src/pages/agent/constant/index.tsx index 3c8a5c8b8..ab9969bb7 100644 --- a/web/src/pages/agent/constant/index.tsx +++ b/web/src/pages/agent/constant/index.tsx @@ -902,3 +902,84 @@ export const ArrayFields = [ TypesWithArray.ArrayString, TypesWithArray.ArrayObject, ]; + +export enum InputMode { + Constant = 'constant', + Variable = 'variable', +} + +export enum LoopTerminationComparisonOperator { + Contains = ComparisonOperator.Contains, + NotContains = ComparisonOperator.NotContains, + StartWith = ComparisonOperator.StartWith, + EndWith = ComparisonOperator.EndWith, + IsEmpty = 'is empty', + IsNotEmpty = 'is not empty', + Is = 'is', + IsNot = 'is not', +} + +export const LoopTerminationStringComparisonOperator = [ + LoopTerminationComparisonOperator.Contains, + LoopTerminationComparisonOperator.NotContains, + LoopTerminationComparisonOperator.StartWith, + LoopTerminationComparisonOperator.EndWith, + LoopTerminationComparisonOperator.Is, + LoopTerminationComparisonOperator.IsNot, + LoopTerminationComparisonOperator.IsEmpty, + LoopTerminationComparisonOperator.IsNotEmpty, +]; + +export const LoopTerminationBooleanComparisonOperator = [ + LoopTerminationComparisonOperator.Is, + LoopTerminationComparisonOperator.IsNot, + LoopTerminationComparisonOperator.IsEmpty, + LoopTerminationComparisonOperator.IsNotEmpty, +]; +// object or object array +export const LoopTerminationObjectComparisonOperator = [ + LoopTerminationComparisonOperator.IsEmpty, + LoopTerminationComparisonOperator.IsNotEmpty, +]; + +// string array or number array +export const LoopTerminationStringArrayComparisonOperator = [ + LoopTerminationComparisonOperator.Contains, + LoopTerminationComparisonOperator.NotContains, + LoopTerminationComparisonOperator.IsEmpty, + LoopTerminationComparisonOperator.IsNotEmpty, +]; + +export const LoopTerminationBooleanArrayComparisonOperator = [ + LoopTerminationComparisonOperator.Is, + LoopTerminationComparisonOperator.IsNot, + LoopTerminationComparisonOperator.IsEmpty, + LoopTerminationComparisonOperator.IsNotEmpty, +]; + +export const LoopTerminationNumberComparisonOperator = [ + ComparisonOperator.Equal, + ComparisonOperator.NotEqual, + ComparisonOperator.GreatThan, + ComparisonOperator.LessThan, + ComparisonOperator.GreatEqual, + ComparisonOperator.LessEqual, + LoopTerminationComparisonOperator.IsEmpty, + LoopTerminationComparisonOperator.IsNotEmpty, +]; + +export const LoopTerminationStringComparisonOperatorMap = { + [TypesWithArray.String]: LoopTerminationStringComparisonOperator, + [TypesWithArray.Number]: LoopTerminationNumberComparisonOperator, + [TypesWithArray.Boolean]: LoopTerminationBooleanComparisonOperator, + [TypesWithArray.Object]: LoopTerminationObjectComparisonOperator, + [TypesWithArray.ArrayString]: LoopTerminationStringArrayComparisonOperator, + [TypesWithArray.ArrayNumber]: LoopTerminationStringArrayComparisonOperator, + [TypesWithArray.ArrayBoolean]: LoopTerminationBooleanArrayComparisonOperator, + [TypesWithArray.ArrayObject]: LoopTerminationObjectComparisonOperator, +}; + +export enum RadioVariable { + Yes = 'yes', + No = 'no', +} diff --git a/web/src/pages/agent/form/loop-form/dynamic-variables.tsx b/web/src/pages/agent/form/loop-form/dynamic-variables.tsx index 9a9b86d90..bd544fdbf 100644 --- a/web/src/pages/agent/form/loop-form/dynamic-variables.tsx +++ b/web/src/pages/agent/form/loop-form/dynamic-variables.tsx @@ -8,26 +8,21 @@ 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 { InputMode, TypesWithArray } from '../../constant'; +import { + InputModeOptions, + 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; @@ -112,12 +107,6 @@ export function DynamicVariables({ (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], ); diff --git a/web/src/pages/agent/form/loop-form/index.tsx b/web/src/pages/agent/form/loop-form/index.tsx index ff4acfc25..a50242565 100644 --- a/web/src/pages/agent/form/loop-form/index.tsx +++ b/web/src/pages/agent/form/loop-form/index.tsx @@ -1,4 +1,6 @@ +import { SliderInputFormField } from '@/components/slider-input-form-field'; import { Form } from '@/components/ui/form'; +import { FormLayout } from '@/constants/form'; import { zodResolver } from '@hookform/resolvers/zod'; import { memo } from 'react'; import { useForm } from 'react-hook-form'; @@ -9,6 +11,7 @@ import { useWatchFormChange } from '../../hooks/use-watch-form-change'; import { INextOperatorForm } from '../../interface'; import { FormWrapper } from '../components/form-wrapper'; import { DynamicVariables } from './dynamic-variables'; +import { LoopTerminationCondition } from './loop-termination-condition'; const FormSchema = z.object({ loop_variables: z.array( @@ -19,14 +22,16 @@ const FormSchema = z.object({ input_mode: z.string(), }), ), + logical_operator: z.string(), loop_termination_condition: z.array( z.object({ variable: z.string().optional(), operator: z.string().optional(), value: z.string().or(z.number()).or(z.boolean()).optional(), - input_mode: z.string(), + input_mode: z.string().optional(), }), ), + maximum_loop_count: z.number(), }); function LoopForm({ node }: INextOperatorForm) { @@ -46,6 +51,17 @@ function LoopForm({ node }: INextOperatorForm) { name="loop_variables" label="Variables" > + + ); diff --git a/web/src/pages/agent/form/loop-form/loop-termination-condition.tsx b/web/src/pages/agent/form/loop-form/loop-termination-condition.tsx new file mode 100644 index 000000000..358adf467 --- /dev/null +++ b/web/src/pages/agent/form/loop-form/loop-termination-condition.tsx @@ -0,0 +1,333 @@ +import { LogicalOperator } from '@/components/logical-operator'; +import { SelectWithSearch } from '@/components/originui/select-with-search'; +import { RAGFlowFormItem } from '@/components/ragflow-form'; +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 { SwitchLogicOperator } from '@/constants/agent'; +import { loader } from '@monaco-editor/react'; +import * as RadioGroupPrimitive from '@radix-ui/react-radio-group'; +import { toLower } from 'lodash'; +import { X } from 'lucide-react'; +import { ReactNode, useCallback } from 'react'; +import { useFieldArray, useFormContext } from 'react-hook-form'; +import { + InputMode, + JsonSchemaDataType, + LoopTerminationComparisonOperator, + RadioVariable, +} from '../../constant'; +import { useGetVariableLabelOrTypeByValue } from '../../hooks/use-get-begin-query'; +import { InputModeOptions } from '../../utils'; +import { DynamicFormHeader } from '../components/dynamic-fom-header'; +import { QueryVariable } from '../components/query-variable'; +import { useBuildLogicalOptions } from './use-build-logical-options'; + +loader.config({ paths: { vs: '/vs' } }); + +type SelectKeysProps = { + name: string; + label: ReactNode; + tooltip?: string; + keyField?: string; + valueField?: string; + operatorField?: string; + modeField?: string; +}; + +type RadioGroupProps = React.ComponentProps; + +type RadioButtonProps = Partial< + Omit & { + onChange: RadioGroupProps['onValueChange']; + } +>; + +function RadioButton({ value, onChange }: RadioButtonProps) { + return ( + +
+ + +
+
+ + +
+
+ ); +} + +const EmptyFields = [ + LoopTerminationComparisonOperator.IsEmpty, + LoopTerminationComparisonOperator.IsNotEmpty, +]; + +const LogicalOperatorFieldName = 'logical_operator'; + +export function LoopTerminationCondition({ + name, + label, + tooltip, + keyField = 'variable', + valueField = 'value', + operatorField = 'operator', + modeField = 'input_mode', +}: SelectKeysProps) { + const form = useFormContext(); + const { getType } = useGetVariableLabelOrTypeByValue(); + + const { fields, remove, append } = useFieldArray({ + name: name, + control: form.control, + }); + + const { buildLogicalOptions } = useBuildLogicalOptions(); + + const getVariableType = useCallback( + (keyFieldName: string) => { + const key = form.getValues(keyFieldName); + return toLower(getType(key)); + }, + [form, getType], + ); + + const initializeMode = useCallback( + (modeFieldAlias: string, keyFieldAlias: string) => { + const keyType = getVariableType(keyFieldAlias); + + if (keyType === JsonSchemaDataType.Number) { + form.setValue(modeFieldAlias, InputMode.Constant, { + shouldDirty: true, + shouldValidate: true, + }); + } + }, + [form, getVariableType], + ); + + const initializeValue = useCallback( + (valueFieldAlias: string, keyFieldAlias: string) => { + const keyType = getVariableType(keyFieldAlias); + let initialValue: string | boolean | number = ''; + + if (keyType === JsonSchemaDataType.Number) { + initialValue = 0; + } else if (keyType === JsonSchemaDataType.Boolean) { + initialValue = RadioVariable.Yes; + } + + form.setValue(valueFieldAlias, initialValue, { + shouldDirty: true, + shouldValidate: true, + }); + }, + [form, getVariableType], + ); + + const handleVariableChange = useCallback( + ( + operatorFieldAlias: string, + valueFieldAlias: string, + keyFieldAlias: string, + modeFieldAlias: string, + ) => { + return () => { + const logicalOptions = buildLogicalOptions( + getVariableType(keyFieldAlias), + ); + + form.setValue(operatorFieldAlias, logicalOptions?.at(0)?.value, { + shouldDirty: true, + shouldValidate: true, + }); + + initializeMode(modeFieldAlias, keyFieldAlias); + + initializeValue(valueFieldAlias, keyFieldAlias); + }; + }, + [ + buildLogicalOptions, + form, + getVariableType, + initializeMode, + initializeValue, + ], + ); + + const handleOperatorChange = useCallback( + ( + valueFieldAlias: string, + keyFieldAlias: string, + modeFieldAlias: string, + ) => { + initializeMode(modeFieldAlias, keyFieldAlias); + initializeValue(valueFieldAlias, keyFieldAlias); + }, + [initializeMode, initializeValue], + ); + + const handleModeChange = useCallback( + (mode: string, valueFieldAlias: string) => { + form.setValue(valueFieldAlias, mode === InputMode.Constant ? 0 : '', { + shouldDirty: true, + }); + }, + [form], + ); + + const renderParameterPanel = useCallback( + ( + keyFieldName: string, + valueFieldAlias: string, + modeFieldAlias: string, + operatorFieldAlias: string, + ) => { + const type = getVariableType(keyFieldName); + const mode = form.getValues(modeFieldAlias); + const operator = form.getValues(operatorFieldAlias); + + if (EmptyFields.includes(operator)) { + return null; + } + + if (type === JsonSchemaDataType.Number) { + return ( +
+ + {(field) => ( + { + handleModeChange(val, valueFieldAlias); + field.onChange(val); + }} + options={InputModeOptions} + > + )} + + + {mode === InputMode.Constant ? ( + + + + ) : ( + + )} +
+ ); + } + + if (type === JsonSchemaDataType.Boolean) { + return ( + + + + ); + } + + return ( + + + + ); + }, + [form, getVariableType, handleModeChange], + ); + + return ( +
+ { + if (fields.length === 1) { + form.setValue(LogicalOperatorFieldName, SwitchLogicOperator.And); + } + append({ [keyField]: '', [valueField]: '' }); + }} + > +
+ {fields.length > 1 && ( + + )} +
+ {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 ( +
+
+
+ + + + + + {({ onChange, value }) => ( + { + handleOperatorChange( + valueFieldAlias, + keyFieldAlias, + modeFieldAlias, + ); + onChange(val); + }} + options={buildLogicalOptions( + getVariableType(keyFieldAlias), + )} + > + )} + +
+ {renderParameterPanel( + keyFieldAlias, + valueFieldAlias, + modeFieldAlias, + operatorFieldAlias, + )} +
+ + +
+ ); + })} +
+
+
+ ); +} diff --git a/web/src/pages/agent/form/loop-form/use-build-logical-options.ts b/web/src/pages/agent/form/loop-form/use-build-logical-options.ts new file mode 100644 index 000000000..e186ee1b2 --- /dev/null +++ b/web/src/pages/agent/form/loop-form/use-build-logical-options.ts @@ -0,0 +1,18 @@ +import { lowerCase } from 'lodash'; +import { useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import { LoopTerminationStringComparisonOperatorMap } from '../../constant'; + +export function useBuildLogicalOptions() { + const { t } = useTranslation(); + + const buildLogicalOptions = useCallback((type: string) => { + return LoopTerminationStringComparisonOperatorMap[ + lowerCase(type) as keyof typeof LoopTerminationStringComparisonOperatorMap + ]?.map((x) => ({ label: x, value: x })); + }, []); + + return { + buildLogicalOptions, + }; +} diff --git a/web/src/pages/agent/utils.ts b/web/src/pages/agent/utils.ts index 6ae2935b4..da2fbf267 100644 --- a/web/src/pages/agent/utils.ts +++ b/web/src/pages/agent/utils.ts @@ -8,7 +8,7 @@ import { } from '@/interfaces/database/agent'; import { DSLComponents, RAGFlowNodeType } from '@/interfaces/database/flow'; import { buildSelectOptions } from '@/utils/component-util'; -import { removeUselessFieldsFromValues } from '@/utils/form'; +import { buildOptions, removeUselessFieldsFromValues } from '@/utils/form'; import { Edge, Node, XYPosition } from '@xyflow/react'; import { FormInstance, FormListFieldData } from 'antd'; import { humanId } from 'human-id'; @@ -27,6 +27,7 @@ import { CategorizeAnchorPointPositions, FileType, FileTypeSuffixMap, + InputMode, NoCopyOperatorsList, NoDebugOperatorsList, NodeHandleId, @@ -772,3 +773,5 @@ export function getArrayElementType(type: string) { export function buildConversationVariableSelectOptions() { return buildSelectOptions(Object.values(TypesWithArray)); } + +export const InputModeOptions = buildOptions(InputMode);