mirror of
https://github.com/infiniflow/ragflow.git
synced 2025-12-08 20:42:30 +08:00
### 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:
@ -8,4 +8,7 @@
|
|||||||
border: 0;
|
border: 0;
|
||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
}
|
}
|
||||||
|
:global(.react-flow__node-group.selectable.selected) {
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -8,7 +8,6 @@ import { memo } from 'react';
|
|||||||
import { NodeHandleId, Operator } from '../../constant';
|
import { NodeHandleId, Operator } from '../../constant';
|
||||||
import OperatorIcon from '../../operator-icon';
|
import OperatorIcon from '../../operator-icon';
|
||||||
import { CommonHandle, LeftEndHandle } from './handle';
|
import { CommonHandle, LeftEndHandle } from './handle';
|
||||||
import styles from './index.less';
|
|
||||||
import NodeHeader from './node-header';
|
import NodeHeader from './node-header';
|
||||||
import { NodeWrapper } from './node-wrapper';
|
import { NodeWrapper } from './node-wrapper';
|
||||||
import { ResizeIcon, controlStyle } from './resize-icon';
|
import { ResizeIcon, controlStyle } from './resize-icon';
|
||||||
@ -23,9 +22,12 @@ export function InnerIterationNode({
|
|||||||
return (
|
return (
|
||||||
<ToolBar selected={selected} id={id} label={data.label} showRun={false}>
|
<ToolBar selected={selected} id={id} label={data.label} showRun={false}>
|
||||||
<section
|
<section
|
||||||
className={cn('h-full bg-transparent rounded-b-md group', {
|
className={cn(
|
||||||
[styles.selectedHeader]: selected,
|
'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}>
|
<NodeResizeControl style={controlStyle} minWidth={100} minHeight={50}>
|
||||||
<ResizeIcon />
|
<ResizeIcon />
|
||||||
@ -43,9 +45,9 @@ export function InnerIterationNode({
|
|||||||
name={data.name}
|
name={data.name}
|
||||||
label={data.label}
|
label={data.label}
|
||||||
wrapperClassName={cn(
|
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>
|
></NodeHeader>
|
||||||
|
|||||||
253
web/src/pages/agent/form/iteration-form/dynamic-variables.tsx
Normal file
253
web/src/pages/agent/form/iteration-form/dynamic-variables.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,4 +1,3 @@
|
|||||||
import { FormContainer } from '@/components/form-container';
|
|
||||||
import { Form } from '@/components/ui/form';
|
import { Form } from '@/components/ui/form';
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { memo, useMemo } from 'react';
|
import { memo, useMemo } from 'react';
|
||||||
@ -10,12 +9,21 @@ import { FormWrapper } from '../components/form-wrapper';
|
|||||||
import { Output } from '../components/output';
|
import { Output } from '../components/output';
|
||||||
import { QueryVariable } from '../components/query-variable';
|
import { QueryVariable } from '../components/query-variable';
|
||||||
import { DynamicOutput } from './dynamic-output';
|
import { DynamicOutput } from './dynamic-output';
|
||||||
|
import { DynamicVariables } from './dynamic-variables';
|
||||||
import { OutputArray } from './interface';
|
import { OutputArray } from './interface';
|
||||||
import { useValues } from './use-values';
|
import { useValues } from './use-values';
|
||||||
import { useWatchFormChange } from './use-watch-form-change';
|
import { useWatchFormChange } from './use-watch-form-change';
|
||||||
|
|
||||||
const FormSchema = z.object({
|
const FormSchema = z.object({
|
||||||
query: z.string().optional(),
|
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(),
|
outputs: z.array(z.object({ name: z.string(), value: z.any() })).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -41,12 +49,11 @@ function IterationForm({ node }: INextOperatorForm) {
|
|||||||
return (
|
return (
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<FormWrapper>
|
<FormWrapper>
|
||||||
<FormContainer>
|
|
||||||
<QueryVariable
|
<QueryVariable
|
||||||
name="items_ref"
|
name="items_ref"
|
||||||
types={ArrayFields as any[]}
|
types={ArrayFields as any[]}
|
||||||
></QueryVariable>
|
></QueryVariable>
|
||||||
</FormContainer>
|
<DynamicVariables name="variables" label="Variables"></DynamicVariables>
|
||||||
<DynamicOutput node={node}></DynamicOutput>
|
<DynamicOutput node={node}></DynamicOutput>
|
||||||
<Output list={outputList}></Output>
|
<Output list={outputList}></Output>
|
||||||
</FormWrapper>
|
</FormWrapper>
|
||||||
|
|||||||
@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -1,7 +1,7 @@
|
|||||||
import { FormFieldConfig, FormFieldType } from '@/components/dynamic-form';
|
import { FormFieldConfig, FormFieldType } from '@/components/dynamic-form';
|
||||||
import { buildSelectOptions } from '@/utils/component-util';
|
|
||||||
import { t } from 'i18next';
|
import { t } from 'i18next';
|
||||||
import { TypesWithArray } from '../constant';
|
import { TypesWithArray } from '../constant';
|
||||||
|
import { buildConversationVariableSelectOptions } from '../utils';
|
||||||
export { TypesWithArray } from '../constant';
|
export { TypesWithArray } from '../constant';
|
||||||
// const TypesWithoutArray = Object.values(JsonSchemaDataType).filter(
|
// const TypesWithoutArray = Object.values(JsonSchemaDataType).filter(
|
||||||
// (item) => item !== JsonSchemaDataType.Array,
|
// (item) => item !== JsonSchemaDataType.Array,
|
||||||
@ -29,7 +29,7 @@ export const GlobalFormFields = [
|
|||||||
placeholder: '',
|
placeholder: '',
|
||||||
required: true,
|
required: true,
|
||||||
type: FormFieldType.Select,
|
type: FormFieldType.Select,
|
||||||
options: buildSelectOptions(Object.values(TypesWithArray)),
|
options: buildConversationVariableSelectOptions(),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: t('flow.defaultValue'),
|
label: t('flow.defaultValue'),
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import {
|
|||||||
ICategorizeItemResult,
|
ICategorizeItemResult,
|
||||||
} from '@/interfaces/database/agent';
|
} from '@/interfaces/database/agent';
|
||||||
import { DSLComponents, RAGFlowNodeType } from '@/interfaces/database/flow';
|
import { DSLComponents, RAGFlowNodeType } from '@/interfaces/database/flow';
|
||||||
|
import { buildSelectOptions } from '@/utils/component-util';
|
||||||
import { removeUselessFieldsFromValues } from '@/utils/form';
|
import { removeUselessFieldsFromValues } from '@/utils/form';
|
||||||
import { Edge, Node, XYPosition } from '@xyflow/react';
|
import { Edge, Node, XYPosition } from '@xyflow/react';
|
||||||
import { FormInstance, FormListFieldData } from 'antd';
|
import { FormInstance, FormListFieldData } from 'antd';
|
||||||
@ -30,6 +31,7 @@ import {
|
|||||||
NoDebugOperatorsList,
|
NoDebugOperatorsList,
|
||||||
NodeHandleId,
|
NodeHandleId,
|
||||||
Operator,
|
Operator,
|
||||||
|
TypesWithArray,
|
||||||
} from './constant';
|
} from './constant';
|
||||||
import { DataOperationsFormSchemaType } from './form/data-operations-form';
|
import { DataOperationsFormSchemaType } from './form/data-operations-form';
|
||||||
import { ExtractorFormSchemaType } from './form/extractor-form';
|
import { ExtractorFormSchemaType } from './form/extractor-form';
|
||||||
@ -766,3 +768,7 @@ export function buildBeginQueryWithObject(
|
|||||||
export function getArrayElementType(type: string) {
|
export function getArrayElementType(type: string) {
|
||||||
return typeof type === 'string' ? type.match(/<([^>]+)>/)?.at(1) ?? '' : '';
|
return typeof type === 'string' ? type.match(/<([^>]+)>/)?.at(1) ?? '' : '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function buildConversationVariableSelectOptions() {
|
||||||
|
return buildSelectOptions(Object.values(TypesWithArray));
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user