mirror of
https://github.com/infiniflow/ragflow.git
synced 2025-12-08 20:42:30 +08:00
### What problem does this PR solve? Fix: Optimized variable node display and Agent template multi-language support #3221 - Modified the VariableNode component to add parent label and icon properties - Updated the VariablePickerMenuPlugin to support displaying parent labels and icons - Adjusted useBuildNodeOutputOptions and useBuildBeginVariableOptions to pass new properties - Optimized the Agent TemplateCard component to switch the title and description based on the language ### Type of change - [x] Bug Fix (non-breaking change which fixes an issue)
This commit is contained in:
@ -235,7 +235,7 @@ function MarkdownContent({
|
|||||||
<HoverCardTrigger>
|
<HoverCardTrigger>
|
||||||
<CircleAlert className="size-4 inline-block" />
|
<CircleAlert className="size-4 inline-block" />
|
||||||
</HoverCardTrigger>
|
</HoverCardTrigger>
|
||||||
<HoverCardContent>
|
<HoverCardContent className="max-w-3xl">
|
||||||
{renderPopoverContent(chunkIndex)}
|
{renderPopoverContent(chunkIndex)}
|
||||||
</HoverCardContent>
|
</HoverCardContent>
|
||||||
</HoverCard>
|
</HoverCard>
|
||||||
|
|||||||
@ -48,10 +48,16 @@ export interface IFlowTemplate {
|
|||||||
canvas_type: string;
|
canvas_type: string;
|
||||||
create_date: string;
|
create_date: string;
|
||||||
create_time: number;
|
create_time: number;
|
||||||
description: string;
|
description: {
|
||||||
|
en: string;
|
||||||
|
zh: string;
|
||||||
|
};
|
||||||
dsl: DSL;
|
dsl: DSL;
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: {
|
||||||
|
en: string;
|
||||||
|
zh: string;
|
||||||
|
};
|
||||||
update_date: string;
|
update_date: string;
|
||||||
update_time: number;
|
update_time: number;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -10,7 +10,6 @@ import { HeadingNode, QuoteNode } from '@lexical/rich-text';
|
|||||||
import {
|
import {
|
||||||
$getRoot,
|
$getRoot,
|
||||||
$getSelection,
|
$getSelection,
|
||||||
$nodesOfType,
|
|
||||||
EditorState,
|
EditorState,
|
||||||
Klass,
|
Klass,
|
||||||
LexicalNode,
|
LexicalNode,
|
||||||
@ -135,9 +134,8 @@ export function PromptEditor({
|
|||||||
const onValueChange = useCallback(
|
const onValueChange = useCallback(
|
||||||
(editorState: EditorState) => {
|
(editorState: EditorState) => {
|
||||||
editorState?.read(() => {
|
editorState?.read(() => {
|
||||||
const listNodes = $nodesOfType(VariableNode); // to be removed
|
// const listNodes = $nodesOfType(VariableNode); // to be removed
|
||||||
// const allNodes = $dfs();
|
// const allNodes = $dfs();
|
||||||
console.log('🚀 ~ onChange ~ allNodes:', listNodes);
|
|
||||||
|
|
||||||
const text = $getRoot().getTextContent();
|
const text = $getRoot().getTextContent();
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,3 @@
|
|||||||
import i18n from '@/locales/config';
|
|
||||||
import { BeginId } from '@/pages/flow/constant';
|
import { BeginId } from '@/pages/flow/constant';
|
||||||
import { DecoratorNode, LexicalNode, NodeKey } from 'lexical';
|
import { DecoratorNode, LexicalNode, NodeKey } from 'lexical';
|
||||||
import { ReactNode } from 'react';
|
import { ReactNode } from 'react';
|
||||||
@ -7,19 +6,36 @@ const prefix = BeginId + '@';
|
|||||||
export class VariableNode extends DecoratorNode<ReactNode> {
|
export class VariableNode extends DecoratorNode<ReactNode> {
|
||||||
__value: string;
|
__value: string;
|
||||||
__label: string;
|
__label: string;
|
||||||
|
key?: NodeKey;
|
||||||
|
__parentLabel?: string | ReactNode;
|
||||||
|
__icon?: ReactNode;
|
||||||
|
|
||||||
static getType(): string {
|
static getType(): string {
|
||||||
return 'variable';
|
return 'variable';
|
||||||
}
|
}
|
||||||
|
|
||||||
static clone(node: VariableNode): VariableNode {
|
static clone(node: VariableNode): VariableNode {
|
||||||
return new VariableNode(node.__value, node.__label, node.__key);
|
return new VariableNode(
|
||||||
|
node.__value,
|
||||||
|
node.__label,
|
||||||
|
node.__key,
|
||||||
|
node.__parentLabel,
|
||||||
|
node.__icon,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
constructor(value: string, label: string, key?: NodeKey) {
|
constructor(
|
||||||
|
value: string,
|
||||||
|
label: string,
|
||||||
|
key?: NodeKey,
|
||||||
|
parent?: string | ReactNode,
|
||||||
|
icon?: ReactNode,
|
||||||
|
) {
|
||||||
super(key);
|
super(key);
|
||||||
this.__value = value;
|
this.__value = value;
|
||||||
this.__label = label;
|
this.__label = label;
|
||||||
|
this.__parentLabel = parent;
|
||||||
|
this.__icon = icon;
|
||||||
}
|
}
|
||||||
|
|
||||||
createDOM(): HTMLElement {
|
createDOM(): HTMLElement {
|
||||||
@ -35,17 +51,20 @@ export class VariableNode extends DecoratorNode<ReactNode> {
|
|||||||
|
|
||||||
decorate(): ReactNode {
|
decorate(): ReactNode {
|
||||||
let content: ReactNode = (
|
let content: ReactNode = (
|
||||||
<span className="text-blue-600">{this.__label}</span>
|
<div className="text-blue-600">{this.__label}</div>
|
||||||
);
|
);
|
||||||
if (this.__value?.startsWith(prefix)) {
|
if (this.__parentLabel) {
|
||||||
content = (
|
content = (
|
||||||
<div>
|
<div className="flex items-center gap-1 text-text-primary ">
|
||||||
<span>{i18n.t(`flow.begin`)}</span> / {content}
|
<div>{this.__icon}</div>
|
||||||
|
<div>{this.__parentLabel}</div>
|
||||||
|
<div className="text-text-disabled mr-1">/</div>
|
||||||
|
{content}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<div className="bg-gray-200 dark:bg-gray-400 text-primary inline-flex items-center rounded-md px-2 py-0">
|
<div className="bg-gray-200 dark:bg-gray-400 text-sm inline-flex items-center rounded-md px-2 py-1">
|
||||||
{content}
|
{content}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@ -59,8 +78,10 @@ export class VariableNode extends DecoratorNode<ReactNode> {
|
|||||||
export function $createVariableNode(
|
export function $createVariableNode(
|
||||||
value: string,
|
value: string,
|
||||||
label: string,
|
label: string,
|
||||||
|
parentLabel: string | ReactNode,
|
||||||
|
icon?: ReactNode,
|
||||||
): VariableNode {
|
): VariableNode {
|
||||||
return new VariableNode(value, label);
|
return new VariableNode(value, label, undefined, parentLabel, icon);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function $isVariableNode(
|
export function $isVariableNode(
|
||||||
|
|||||||
@ -20,7 +20,13 @@ import {
|
|||||||
$isRangeSelection,
|
$isRangeSelection,
|
||||||
TextNode,
|
TextNode,
|
||||||
} from 'lexical';
|
} from 'lexical';
|
||||||
import React, { ReactElement, useCallback, useEffect, useRef } from 'react';
|
import React, {
|
||||||
|
ReactElement,
|
||||||
|
ReactNode,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useRef,
|
||||||
|
} from 'react';
|
||||||
import * as ReactDOM from 'react-dom';
|
import * as ReactDOM from 'react-dom';
|
||||||
|
|
||||||
import { $createVariableNode } from './variable-node';
|
import { $createVariableNode } from './variable-node';
|
||||||
@ -31,11 +37,20 @@ import './index.css';
|
|||||||
class VariableInnerOption extends MenuOption {
|
class VariableInnerOption extends MenuOption {
|
||||||
label: string;
|
label: string;
|
||||||
value: string;
|
value: string;
|
||||||
|
parentLabel: string | JSX.Element;
|
||||||
|
icon?: ReactNode;
|
||||||
|
|
||||||
constructor(label: string, value: string) {
|
constructor(
|
||||||
|
label: string,
|
||||||
|
value: string,
|
||||||
|
parentLabel: string | JSX.Element,
|
||||||
|
icon?: ReactNode,
|
||||||
|
) {
|
||||||
super(value);
|
super(value);
|
||||||
this.label = label;
|
this.label = label;
|
||||||
this.value = value;
|
this.value = value;
|
||||||
|
this.parentLabel = parentLabel;
|
||||||
|
this.icon = icon;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -111,7 +126,6 @@ export default function VariablePickerMenuPlugin({
|
|||||||
|
|
||||||
const buildNextOptions = useCallback(() => {
|
const buildNextOptions = useCallback(() => {
|
||||||
let filteredOptions = options;
|
let filteredOptions = options;
|
||||||
|
|
||||||
if (queryString) {
|
if (queryString) {
|
||||||
const lowerQuery = queryString.toLowerCase();
|
const lowerQuery = queryString.toLowerCase();
|
||||||
filteredOptions = options
|
filteredOptions = options
|
||||||
@ -131,23 +145,28 @@ export default function VariablePickerMenuPlugin({
|
|||||||
new VariableOption(
|
new VariableOption(
|
||||||
x.label,
|
x.label,
|
||||||
x.title,
|
x.title,
|
||||||
x.options.map((y) => new VariableInnerOption(y.label, y.value)),
|
x.options.map((y) => {
|
||||||
|
return new VariableInnerOption(y.label, y.value, x.label, y.icon);
|
||||||
|
}),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
return nextOptions;
|
return nextOptions;
|
||||||
}, [options, queryString]);
|
}, [options, queryString]);
|
||||||
|
|
||||||
const findLabelByValue = useCallback(
|
const findItemByValue = useCallback(
|
||||||
(value: string) => {
|
(value: string) => {
|
||||||
const children = options.reduce<Array<{ label: string; value: string }>>(
|
const children = options.reduce<
|
||||||
(pre, cur) => {
|
Array<{
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
parentLabel?: string | ReactNode;
|
||||||
|
icon?: ReactNode;
|
||||||
|
}>
|
||||||
|
>((pre, cur) => {
|
||||||
return pre.concat(cur.options);
|
return pre.concat(cur.options);
|
||||||
},
|
}, []);
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
return children.find((x) => x.value === value)?.label;
|
return children.find((x) => x.value === value);
|
||||||
},
|
},
|
||||||
[options],
|
[options],
|
||||||
);
|
);
|
||||||
@ -168,13 +187,13 @@ export default function VariablePickerMenuPlugin({
|
|||||||
if (nodeToRemove) {
|
if (nodeToRemove) {
|
||||||
nodeToRemove.remove();
|
nodeToRemove.remove();
|
||||||
}
|
}
|
||||||
|
const variableNode = $createVariableNode(
|
||||||
selection.insertNodes([
|
|
||||||
$createVariableNode(
|
|
||||||
(selectedOption as VariableInnerOption).value,
|
(selectedOption as VariableInnerOption).value,
|
||||||
selectedOption.label as string,
|
selectedOption.label as string,
|
||||||
),
|
selectedOption.parentLabel as string | ReactNode,
|
||||||
]);
|
selectedOption.icon as ReactNode,
|
||||||
|
);
|
||||||
|
selection.insertNodes([variableNode]);
|
||||||
|
|
||||||
closeMenu();
|
closeMenu();
|
||||||
});
|
});
|
||||||
@ -190,7 +209,6 @@ export default function VariablePickerMenuPlugin({
|
|||||||
const regex = /{([^}]*)}/g;
|
const regex = /{([^}]*)}/g;
|
||||||
let match;
|
let match;
|
||||||
let lastIndex = 0;
|
let lastIndex = 0;
|
||||||
|
|
||||||
while ((match = regex.exec(text)) !== null) {
|
while ((match = regex.exec(text)) !== null) {
|
||||||
const { 1: content, index, 0: template } = match;
|
const { 1: content, index, 0: template } = match;
|
||||||
|
|
||||||
@ -202,9 +220,17 @@ export default function VariablePickerMenuPlugin({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Add variable node or text node
|
// Add variable node or text node
|
||||||
const label = findLabelByValue(content);
|
const nodeItem = findItemByValue(content);
|
||||||
if (label) {
|
|
||||||
paragraph.append($createVariableNode(content, label));
|
if (nodeItem) {
|
||||||
|
paragraph.append(
|
||||||
|
$createVariableNode(
|
||||||
|
content,
|
||||||
|
nodeItem.label,
|
||||||
|
nodeItem.parentLabel,
|
||||||
|
nodeItem.icon,
|
||||||
|
),
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
paragraph.append($createTextNode(template));
|
paragraph.append($createTextNode(template));
|
||||||
}
|
}
|
||||||
@ -225,7 +251,7 @@ export default function VariablePickerMenuPlugin({
|
|||||||
$getRoot().selectEnd();
|
$getRoot().selectEnd();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[findLabelByValue],
|
[findItemByValue],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@ -6,7 +6,14 @@ import { DefaultOptionType } from 'antd/es/select';
|
|||||||
import { t } from 'i18next';
|
import { t } from 'i18next';
|
||||||
import { isEmpty } from 'lodash';
|
import { isEmpty } from 'lodash';
|
||||||
import get from 'lodash/get';
|
import get from 'lodash/get';
|
||||||
import { useCallback, useContext, useEffect, useMemo, useState } from 'react';
|
import {
|
||||||
|
ReactNode,
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useState,
|
||||||
|
} from 'react';
|
||||||
import {
|
import {
|
||||||
AgentDialogueMode,
|
AgentDialogueMode,
|
||||||
BeginId,
|
BeginId,
|
||||||
@ -17,6 +24,7 @@ import {
|
|||||||
import { AgentFormContext } from '../context';
|
import { AgentFormContext } from '../context';
|
||||||
import { buildBeginInputListFromObject } from '../form/begin-form/utils';
|
import { buildBeginInputListFromObject } from '../form/begin-form/utils';
|
||||||
import { BeginQuery } from '../interface';
|
import { BeginQuery } from '../interface';
|
||||||
|
import OperatorIcon from '../operator-icon';
|
||||||
import useGraphStore from '../store';
|
import useGraphStore from '../store';
|
||||||
|
|
||||||
export function useSelectBeginNodeDataInputs() {
|
export function useSelectBeginNodeDataInputs() {
|
||||||
@ -98,10 +106,14 @@ function filterAllUpstreamNodeIds(edges: Edge[], nodeIds: string[]) {
|
|||||||
export function buildOutputOptions(
|
export function buildOutputOptions(
|
||||||
outputs: Record<string, any> = {},
|
outputs: Record<string, any> = {},
|
||||||
nodeId?: string,
|
nodeId?: string,
|
||||||
|
parentLabel?: string | ReactNode,
|
||||||
|
icon?: ReactNode,
|
||||||
) {
|
) {
|
||||||
return Object.keys(outputs).map((x) => ({
|
return Object.keys(outputs).map((x) => ({
|
||||||
label: x,
|
label: x,
|
||||||
value: `${nodeId}@${x}`,
|
value: `${nodeId}@${x}`,
|
||||||
|
parentLabel,
|
||||||
|
icon,
|
||||||
type: outputs[x]?.type,
|
type: outputs[x]?.type,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
@ -127,7 +139,12 @@ export function useBuildNodeOutputOptions(nodeId?: string) {
|
|||||||
label: x.data.name,
|
label: x.data.name,
|
||||||
value: x.id,
|
value: x.id,
|
||||||
title: x.data.name,
|
title: x.data.name,
|
||||||
options: buildOutputOptions(x.data.form.outputs, x.id),
|
options: buildOutputOptions(
|
||||||
|
x.data.form.outputs,
|
||||||
|
x.id,
|
||||||
|
x.data.name,
|
||||||
|
<OperatorIcon name={x.data.label as Operator} />,
|
||||||
|
),
|
||||||
}));
|
}));
|
||||||
}, [edges, nodeId, nodes]);
|
}, [edges, nodeId, nodes]);
|
||||||
|
|
||||||
@ -162,9 +179,11 @@ export function useBuildBeginVariableOptions() {
|
|||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
label: <span>{t('flow.beginInput')}</span>,
|
label: <span>{t('flow.beginInput')}</span>,
|
||||||
title: 'Begin Input',
|
title: t('flow.beginInput'),
|
||||||
options: inputs.map((x) => ({
|
options: inputs.map((x) => ({
|
||||||
label: x.name,
|
label: x.name,
|
||||||
|
parentLabel: <span>{t('flow.beginInput')}</span>,
|
||||||
|
icon: <OperatorIcon name={Operator.Begin} className="block" />,
|
||||||
value: `begin@${x.key}`,
|
value: `begin@${x.key}`,
|
||||||
type: transferToVariableType(x.type),
|
type: transferToVariableType(x.type),
|
||||||
})),
|
})),
|
||||||
@ -191,12 +210,13 @@ export function useBuildQueryVariableOptions(n?: RAGFlowNodeType) {
|
|||||||
const { data } = useFetchAgent();
|
const { data } = useFetchAgent();
|
||||||
const node = useContext(AgentFormContext) || n;
|
const node = useContext(AgentFormContext) || n;
|
||||||
const options = useBuildVariableOptions(node?.id, node?.parentId);
|
const options = useBuildVariableOptions(node?.id, node?.parentId);
|
||||||
|
|
||||||
const nextOptions = useMemo(() => {
|
const nextOptions = useMemo(() => {
|
||||||
const globals = data?.dsl?.globals ?? {};
|
const globals = data?.dsl?.globals ?? {};
|
||||||
const globalOptions = Object.entries(globals).map(([key, value]) => ({
|
const globalOptions = Object.entries(globals).map(([key, value]) => ({
|
||||||
label: key,
|
label: key,
|
||||||
value: key,
|
value: key,
|
||||||
|
icon: <OperatorIcon name={Operator.Begin} className="block" />,
|
||||||
|
parentLabel: <span>{t('flow.beginInput')}</span>,
|
||||||
type: Array.isArray(value)
|
type: Array.isArray(value)
|
||||||
? `${VariableType.Array}${key === AgentGlobals.SysFiles ? '<file>' : ''}`
|
? `${VariableType.Array}${key === AgentGlobals.SysFiles ? '<file>' : ''}`
|
||||||
: typeof value,
|
: typeof value,
|
||||||
|
|||||||
@ -64,7 +64,12 @@ const OperatorIcon = ({ name, className }: IProps) => {
|
|||||||
|
|
||||||
if (name === Operator.Begin) {
|
if (name === Operator.Begin) {
|
||||||
return (
|
return (
|
||||||
<div className="inline-block p-1 bg-accent-primary rounded-sm">
|
<div
|
||||||
|
className={cn(
|
||||||
|
'inline-block p-1 bg-accent-primary rounded-sm',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
<HousePlus className="rounded size-3" />
|
<HousePlus className="rounded size-3" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -204,6 +204,7 @@ const useGraphStore = create<RFState>()(
|
|||||||
set({ nodes: nextNodes });
|
set({ nodes: nextNodes });
|
||||||
},
|
},
|
||||||
getNode: (id?: string | null) => {
|
getNode: (id?: string | null) => {
|
||||||
|
// console.log('getNode', id, get().nodes);
|
||||||
return get().nodes.find((x) => x.id === id);
|
return get().nodes.find((x) => x.id === id);
|
||||||
},
|
},
|
||||||
getOperatorTypeFromId: (id?: string | null) => {
|
getOperatorTypeFromId: (id?: string | null) => {
|
||||||
|
|||||||
@ -2,10 +2,10 @@ import { RAGFlowAvatar } from '@/components/ragflow-avatar';
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent } from '@/components/ui/card';
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
import { IFlowTemplate } from '@/interfaces/database/flow';
|
import { IFlowTemplate } from '@/interfaces/database/flow';
|
||||||
|
import i18n from '@/locales/config';
|
||||||
import { Plus } from 'lucide-react';
|
import { Plus } from 'lucide-react';
|
||||||
import { useCallback } from 'react';
|
import { useCallback, useMemo } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
interface IProps {
|
interface IProps {
|
||||||
data: IFlowTemplate;
|
data: IFlowTemplate;
|
||||||
isCreate?: boolean;
|
isCreate?: boolean;
|
||||||
@ -18,6 +18,11 @@ export function TemplateCard({ data, showModal, isCreate = false }: IProps) {
|
|||||||
const handleClick = useCallback(() => {
|
const handleClick = useCallback(() => {
|
||||||
showModal(data);
|
showModal(data);
|
||||||
}, [data, showModal]);
|
}, [data, showModal]);
|
||||||
|
|
||||||
|
const language = useMemo(() => {
|
||||||
|
return i18n.language || 'en';
|
||||||
|
}, []) as 'en' | 'zh';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="border-colors-outline-neutral-standard group relative min-h-40">
|
<Card className="border-colors-outline-neutral-standard group relative min-h-40">
|
||||||
<CardContent className="p-4 ">
|
<CardContent className="p-4 ">
|
||||||
@ -38,11 +43,13 @@ export function TemplateCard({ data, showModal, isCreate = false }: IProps) {
|
|||||||
avatar={
|
avatar={
|
||||||
data.avatar ? data.avatar : 'https://github.com/shadcn.png'
|
data.avatar ? data.avatar : 'https://github.com/shadcn.png'
|
||||||
}
|
}
|
||||||
name={data?.title || 'CN'}
|
name={data?.title[language] || 'CN'}
|
||||||
></RAGFlowAvatar>
|
></RAGFlowAvatar>
|
||||||
<div className="text-[18px] font-bold ">{data.title}</div>
|
<div className="text-[18px] font-bold ">
|
||||||
|
{data?.title[language]}
|
||||||
</div>
|
</div>
|
||||||
<p className="break-words">{data.description}</p>
|
</div>
|
||||||
|
<p className="break-words">{data?.description[language]}</p>
|
||||||
<div className="group-hover:bg-gradient-to-t from-black/70 from-10% via-black/0 via-50% to-black/0 w-full h-full group-hover:block absolute top-0 left-0 hidden rounded-xl">
|
<div className="group-hover:bg-gradient-to-t from-black/70 from-10% via-black/0 via-50% to-black/0 w-full h-full group-hover:block absolute top-0 left-0 hidden rounded-xl">
|
||||||
<Button
|
<Button
|
||||||
variant="default"
|
variant="default"
|
||||||
|
|||||||
Reference in New Issue
Block a user