mirror of
https://github.com/langgenius/dify.git
synced 2026-05-06 02:18:08 +08:00
Merge branch 'feat/rag-pipeline' of https://github.com/langgenius/dify into feat/rag-pipeline
This commit is contained in:
@ -39,6 +39,7 @@ export type EmbeddedChatbotContextValue = {
|
||||
chatShouldReloadKey: string
|
||||
isMobile: boolean
|
||||
isInstalledApp: boolean
|
||||
allowResetChat: boolean
|
||||
appId?: string
|
||||
handleFeedback: (messageId: string, feedback: Feedback) => void
|
||||
currentChatInstanceRef: RefObject<{ handleStop: () => void }>
|
||||
@ -67,6 +68,7 @@ export const EmbeddedChatbotContext = createContext<EmbeddedChatbotContextValue>
|
||||
chatShouldReloadKey: '',
|
||||
isMobile: false,
|
||||
isInstalledApp: false,
|
||||
allowResetChat: true,
|
||||
handleFeedback: noop,
|
||||
currentChatInstanceRef: { current: { handleStop: noop } },
|
||||
clearChatList: false,
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import type { FC } from 'react'
|
||||
import React from 'react'
|
||||
import { RiResetLeftLine } from '@remixicon/react'
|
||||
import React, { useCallback, useEffect, useState } from 'react'
|
||||
import { RiCollapseDiagonal2Line, RiExpandDiagonal2Line, RiResetLeftLine } from '@remixicon/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { Theme } from '../theme/theme-context'
|
||||
import { CssTransform } from '../theme/utils'
|
||||
@ -16,6 +16,7 @@ import cn from '@/utils/classnames'
|
||||
|
||||
export type IHeaderProps = {
|
||||
isMobile?: boolean
|
||||
allowResetChat?: boolean
|
||||
customerIcon?: React.ReactNode
|
||||
title: string
|
||||
theme?: Theme
|
||||
@ -23,6 +24,7 @@ export type IHeaderProps = {
|
||||
}
|
||||
const Header: FC<IHeaderProps> = ({
|
||||
isMobile,
|
||||
allowResetChat,
|
||||
customerIcon,
|
||||
title,
|
||||
theme,
|
||||
@ -34,6 +36,44 @@ const Header: FC<IHeaderProps> = ({
|
||||
currentConversationId,
|
||||
inputsForms,
|
||||
} = useEmbeddedChatbotContext()
|
||||
|
||||
const isClient = typeof window !== 'undefined'
|
||||
const isIframe = isClient ? window.self !== window.top : false
|
||||
const [parentOrigin, setParentOrigin] = useState('')
|
||||
const [showToggleExpandButton, setShowToggleExpandButton] = useState(false)
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
|
||||
const handleMessageReceived = useCallback((event: MessageEvent) => {
|
||||
let currentParentOrigin = parentOrigin
|
||||
if (!currentParentOrigin && event.data.type === 'dify-chatbot-config') {
|
||||
currentParentOrigin = event.origin
|
||||
setParentOrigin(event.origin)
|
||||
}
|
||||
if (event.origin !== currentParentOrigin)
|
||||
return
|
||||
if (event.data.type === 'dify-chatbot-config')
|
||||
setShowToggleExpandButton(event.data.payload.isToggledByButton && !event.data.payload.isDraggable)
|
||||
}, [parentOrigin])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isIframe) return
|
||||
|
||||
const listener = (event: MessageEvent) => handleMessageReceived(event)
|
||||
window.addEventListener('message', listener)
|
||||
|
||||
window.parent.postMessage({ type: 'dify-chatbot-iframe-ready' }, '*')
|
||||
|
||||
return () => window.removeEventListener('message', listener)
|
||||
}, [isIframe, handleMessageReceived])
|
||||
|
||||
const handleToggleExpand = useCallback(() => {
|
||||
if (!isIframe || !showToggleExpandButton) return
|
||||
setExpanded(!expanded)
|
||||
window.parent.postMessage({
|
||||
type: 'dify-chatbot-expand-change',
|
||||
}, parentOrigin)
|
||||
}, [isIframe, parentOrigin, showToggleExpandButton, expanded])
|
||||
|
||||
if (!isMobile) {
|
||||
return (
|
||||
<div className='flex h-14 shrink-0 items-center justify-end p-3'>
|
||||
@ -57,7 +97,22 @@ const Header: FC<IHeaderProps> = ({
|
||||
{currentConversationId && (
|
||||
<Divider type='vertical' className='h-3.5' />
|
||||
)}
|
||||
{currentConversationId && (
|
||||
{
|
||||
showToggleExpandButton && (
|
||||
<Tooltip
|
||||
popupContent={expanded ? t('share.chat.collapse') : t('share.chat.expand')}
|
||||
>
|
||||
<ActionButton size='l' onClick={handleToggleExpand}>
|
||||
{
|
||||
expanded
|
||||
? <RiCollapseDiagonal2Line className='h-[18px] w-[18px]' />
|
||||
: <RiExpandDiagonal2Line className='h-[18px] w-[18px]' />
|
||||
}
|
||||
</ActionButton>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
{currentConversationId && allowResetChat && (
|
||||
<Tooltip
|
||||
popupContent={t('share.chat.resetChat')}
|
||||
>
|
||||
@ -89,7 +144,22 @@ const Header: FC<IHeaderProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex items-center gap-1'>
|
||||
{currentConversationId && (
|
||||
{
|
||||
showToggleExpandButton && (
|
||||
<Tooltip
|
||||
popupContent={expanded ? t('share.chat.collapse') : t('share.chat.expand')}
|
||||
>
|
||||
<ActionButton size='l' onClick={handleToggleExpand}>
|
||||
{
|
||||
expanded
|
||||
? <RiCollapseDiagonal2Line className={cn('h-[18px] w-[18px]', theme?.colorPathOnHeader)} />
|
||||
: <RiExpandDiagonal2Line className={cn('h-[18px] w-[18px]', theme?.colorPathOnHeader)} />
|
||||
}
|
||||
</ActionButton>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
{currentConversationId && allowResetChat && (
|
||||
<Tooltip
|
||||
popupContent={t('share.chat.resetChat')}
|
||||
>
|
||||
|
||||
@ -73,9 +73,11 @@ export const useEmbeddedChatbot = () => {
|
||||
const appId = useMemo(() => appData?.app_id, [appData])
|
||||
|
||||
const [userId, setUserId] = useState<string>()
|
||||
const [conversationId, setConversationId] = useState<string>()
|
||||
useEffect(() => {
|
||||
getProcessedSystemVariablesFromUrlParams().then(({ user_id }) => {
|
||||
getProcessedSystemVariablesFromUrlParams().then(({ user_id, conversation_id }) => {
|
||||
setUserId(user_id)
|
||||
setConversationId(conversation_id)
|
||||
})
|
||||
}, [])
|
||||
|
||||
@ -109,7 +111,9 @@ export const useEmbeddedChatbot = () => {
|
||||
const [conversationIdInfo, setConversationIdInfo] = useLocalStorageState<Record<string, Record<string, string>>>(CONVERSATION_ID_INFO, {
|
||||
defaultValue: {},
|
||||
})
|
||||
const currentConversationId = useMemo(() => conversationIdInfo?.[appId || '']?.[userId || 'DEFAULT'] || '', [appId, conversationIdInfo, userId])
|
||||
const allowResetChat = !conversationId
|
||||
const currentConversationId = useMemo(() => conversationIdInfo?.[appId || '']?.[userId || 'DEFAULT'] || conversationId || '',
|
||||
[appId, conversationIdInfo, userId, conversationId])
|
||||
const handleConversationIdInfoChange = useCallback((changeConversationId: string) => {
|
||||
if (appId) {
|
||||
let prevValue = conversationIdInfo?.[appId || '']
|
||||
@ -362,6 +366,7 @@ export const useEmbeddedChatbot = () => {
|
||||
appInfoError,
|
||||
appInfoLoading,
|
||||
isInstalledApp,
|
||||
allowResetChat,
|
||||
appId,
|
||||
currentConversationId,
|
||||
currentConversationItem,
|
||||
|
||||
@ -25,6 +25,7 @@ import cn from '@/utils/classnames'
|
||||
const Chatbot = () => {
|
||||
const {
|
||||
isMobile,
|
||||
allowResetChat,
|
||||
appInfoError,
|
||||
appInfoLoading,
|
||||
appData,
|
||||
@ -90,6 +91,7 @@ const Chatbot = () => {
|
||||
>
|
||||
<Header
|
||||
isMobile={isMobile}
|
||||
allowResetChat={allowResetChat}
|
||||
title={site?.title || ''}
|
||||
customerIcon={isDify() ? difyIcon : ''}
|
||||
theme={themeBuilder?.theme}
|
||||
@ -153,6 +155,7 @@ const EmbeddedChatbotWrapper = () => {
|
||||
handleNewConversationCompleted,
|
||||
chatShouldReloadKey,
|
||||
isInstalledApp,
|
||||
allowResetChat,
|
||||
appId,
|
||||
handleFeedback,
|
||||
currentChatInstanceRef,
|
||||
@ -187,6 +190,7 @@ const EmbeddedChatbotWrapper = () => {
|
||||
chatShouldReloadKey,
|
||||
isMobile,
|
||||
isInstalledApp,
|
||||
allowResetChat,
|
||||
appId,
|
||||
handleFeedback,
|
||||
currentChatInstanceRef,
|
||||
|
||||
@ -0,0 +1,58 @@
|
||||
import { useState } from 'react'
|
||||
import { RiEditLine } from '@remixicon/react'
|
||||
import cn from '@/utils/classnames'
|
||||
import SegmentedControl from '@/app/components/base/segmented-control'
|
||||
import { VariableX } from '@/app/components/base/icons/src/vender/workflow'
|
||||
import type { LabelProps } from '../label'
|
||||
import Label from '../label'
|
||||
|
||||
type VariableOrConstantInputFieldProps = {
|
||||
label: string
|
||||
labelOptions?: Omit<LabelProps, 'htmlFor' | 'label'>
|
||||
className?: string
|
||||
}
|
||||
|
||||
const VariableOrConstantInputField = ({
|
||||
className,
|
||||
label,
|
||||
labelOptions,
|
||||
}: VariableOrConstantInputFieldProps) => {
|
||||
const [variableType, setVariableType] = useState('variable')
|
||||
|
||||
const options = [
|
||||
{
|
||||
Icon: VariableX,
|
||||
text: '',
|
||||
value: 'variable',
|
||||
},
|
||||
{
|
||||
Icon: RiEditLine,
|
||||
text: '',
|
||||
value: 'constant',
|
||||
},
|
||||
]
|
||||
|
||||
const handleVariableOrConstantChange = (value: string) => {
|
||||
setVariableType(value)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('flex flex-col gap-y-0.5', className)}>
|
||||
<Label
|
||||
htmlFor={'variable-or-constant'}
|
||||
label={label}
|
||||
{...(labelOptions ?? {})}
|
||||
/>
|
||||
<div className='flex items-center'>
|
||||
<SegmentedControl
|
||||
className='mr-1 shrink-0'
|
||||
value={variableType}
|
||||
onChange={handleVariableOrConstantChange as any}
|
||||
options={options as any}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default VariableOrConstantInputField
|
||||
239
web/app/components/base/form/form-scenarios/node-panel/field.tsx
Normal file
239
web/app/components/base/form/form-scenarios/node-panel/field.tsx
Normal file
@ -0,0 +1,239 @@
|
||||
import React from 'react'
|
||||
import { type InputFieldConfiguration, InputFieldType } from './types'
|
||||
import { withForm } from '../..'
|
||||
import { useStore } from '@tanstack/react-form'
|
||||
|
||||
type InputFieldProps<T> = {
|
||||
initialData?: T
|
||||
config: InputFieldConfiguration<T>
|
||||
}
|
||||
|
||||
const NodePanelField = <T,>({
|
||||
initialData,
|
||||
config,
|
||||
}: InputFieldProps<T>) => withForm({
|
||||
defaultValues: initialData,
|
||||
render: function Render({
|
||||
form,
|
||||
}) {
|
||||
const {
|
||||
type,
|
||||
label,
|
||||
placeholder,
|
||||
variable,
|
||||
tooltip,
|
||||
showConditions,
|
||||
max,
|
||||
min,
|
||||
required,
|
||||
showOptional,
|
||||
supportFile,
|
||||
description,
|
||||
options,
|
||||
listeners,
|
||||
popupProps,
|
||||
} = config
|
||||
|
||||
const isAllConditionsMet = useStore(form.store, (state) => {
|
||||
const fieldValues = state.values
|
||||
return showConditions.every((condition) => {
|
||||
const { variable, value } = condition
|
||||
const fieldValue = fieldValues[variable as keyof typeof fieldValues]
|
||||
return fieldValue === value
|
||||
})
|
||||
})
|
||||
|
||||
if (!isAllConditionsMet)
|
||||
return <></>
|
||||
|
||||
if (type === InputFieldType.textInput) {
|
||||
return (
|
||||
<form.AppField
|
||||
name={variable}
|
||||
children={field => (
|
||||
<field.TextField
|
||||
label={label}
|
||||
labelOptions={{
|
||||
tooltip,
|
||||
isRequired: required,
|
||||
showOptional,
|
||||
}}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (type === InputFieldType.numberInput) {
|
||||
return (
|
||||
<form.AppField
|
||||
name={variable}
|
||||
children={field => (
|
||||
<field.NumberInputField
|
||||
label={label}
|
||||
labelOptions={{
|
||||
tooltip,
|
||||
isRequired: required,
|
||||
showOptional,
|
||||
}}
|
||||
placeholder={placeholder}
|
||||
max={max}
|
||||
min={min}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (type === InputFieldType.numberSlider) {
|
||||
return (
|
||||
<form.AppField
|
||||
name={variable}
|
||||
children={field => (
|
||||
<field.NumberSliderField
|
||||
label={label}
|
||||
labelOptions={{
|
||||
tooltip,
|
||||
isRequired: required,
|
||||
showOptional,
|
||||
}}
|
||||
description={description}
|
||||
max={max}
|
||||
min={min}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (type === InputFieldType.checkbox) {
|
||||
return (
|
||||
<form.AppField
|
||||
name={variable}
|
||||
children={field => (
|
||||
<field.CheckboxField
|
||||
label={label}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (type === InputFieldType.select) {
|
||||
return (
|
||||
<form.AppField
|
||||
name={variable}
|
||||
children={field => (
|
||||
<field.SelectField
|
||||
label={label}
|
||||
labelOptions={{
|
||||
tooltip,
|
||||
isRequired: required,
|
||||
showOptional,
|
||||
}}
|
||||
options={options!}
|
||||
popupProps={popupProps}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (type === InputFieldType.inputTypeSelect) {
|
||||
return (
|
||||
<form.AppField
|
||||
name={variable}
|
||||
listeners={listeners}
|
||||
children={field => (
|
||||
<field.InputTypeSelectField
|
||||
label={label}
|
||||
labelOptions={{
|
||||
tooltip,
|
||||
isRequired: required,
|
||||
showOptional,
|
||||
}}
|
||||
supportFile={!!supportFile}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (type === InputFieldType.uploadMethod) {
|
||||
return (
|
||||
<form.AppField
|
||||
name={variable}
|
||||
children={field => (
|
||||
<field.UploadMethodField
|
||||
label={label}
|
||||
labelOptions={{
|
||||
tooltip,
|
||||
isRequired: required,
|
||||
showOptional,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (type === InputFieldType.fileTypes) {
|
||||
return (
|
||||
<form.AppField
|
||||
name={variable}
|
||||
children={field => (
|
||||
<field.FileTypesField
|
||||
label={label}
|
||||
labelOptions={{
|
||||
tooltip,
|
||||
isRequired: required,
|
||||
showOptional,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (type === InputFieldType.options) {
|
||||
return (
|
||||
<form.AppField
|
||||
name={variable}
|
||||
children={field => (
|
||||
<field.OptionsField
|
||||
label={label}
|
||||
labelOptions={{
|
||||
tooltip,
|
||||
isRequired: required,
|
||||
showOptional,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (type === InputFieldType.variableOrConstant) {
|
||||
return (
|
||||
<form.AppField
|
||||
name={variable}
|
||||
children={field => (
|
||||
<field.VariableOrConstantInputField
|
||||
label={label}
|
||||
labelOptions={{
|
||||
tooltip,
|
||||
isRequired: required,
|
||||
showOptional,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return <></>
|
||||
},
|
||||
})
|
||||
|
||||
export default NodePanelField
|
||||
@ -0,0 +1,40 @@
|
||||
import type { DeepKeys, FieldListeners } from '@tanstack/react-form'
|
||||
import type { NumberConfiguration, SelectConfiguration, ShowCondition } from '../base/types'
|
||||
|
||||
export enum InputFieldType {
|
||||
textInput = 'textInput',
|
||||
numberInput = 'numberInput',
|
||||
numberSlider = 'numberSlider',
|
||||
checkbox = 'checkbox',
|
||||
options = 'options',
|
||||
select = 'select',
|
||||
inputTypeSelect = 'inputTypeSelect',
|
||||
uploadMethod = 'uploadMethod',
|
||||
fileTypes = 'fileTypes',
|
||||
variableOrConstant = 'variableOrConstant',
|
||||
}
|
||||
|
||||
export type InputTypeSelectConfiguration = {
|
||||
supportFile: boolean
|
||||
}
|
||||
|
||||
export type NumberSliderConfiguration = {
|
||||
description: string
|
||||
max?: number
|
||||
min?: number
|
||||
}
|
||||
|
||||
export type InputFieldConfiguration<T> = {
|
||||
label: string
|
||||
variable: DeepKeys<T> // Variable name
|
||||
maxLength?: number // Max length for text input
|
||||
placeholder?: string
|
||||
required: boolean
|
||||
showOptional?: boolean // show optional label
|
||||
showConditions: ShowCondition<T>[] // Show this field only when all conditions are met
|
||||
type: InputFieldType
|
||||
tooltip?: string // Tooltip for this field
|
||||
listeners?: FieldListeners<T, DeepKeys<T>> // Listener for this field
|
||||
} & NumberConfiguration & Partial<InputTypeSelectConfiguration>
|
||||
& Partial<NumberSliderConfiguration>
|
||||
& Partial<SelectConfiguration>
|
||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 68 KiB |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 68 KiB |
279
web/app/components/base/icons/src/public/tracing/WeaveIcon.json
Normal file
279
web/app/components/base/icons/src/public/tracing/WeaveIcon.json
Normal file
File diff suppressed because one or more lines are too long
@ -0,0 +1,16 @@
|
||||
// GENERATE BY script
|
||||
// DON NOT EDIT IT MANUALLY
|
||||
|
||||
import * as React from 'react'
|
||||
import data from './WeaveIcon.json'
|
||||
import IconBase from '@/app/components/base/icons/IconBase'
|
||||
import type { IconBaseProps, IconData } from '@/app/components/base/icons/IconBase'
|
||||
|
||||
const Icon = React.forwardRef<React.MutableRefObject<SVGElement>, Omit<IconBaseProps, 'data'>>((
|
||||
props,
|
||||
ref,
|
||||
) => <IconBase {...props} ref={ref} data={data as IconData} />)
|
||||
|
||||
Icon.displayName = 'WeaveIcon'
|
||||
|
||||
export default Icon
|
||||
File diff suppressed because one or more lines are too long
@ -0,0 +1,16 @@
|
||||
// GENERATE BY script
|
||||
// DON NOT EDIT IT MANUALLY
|
||||
|
||||
import * as React from 'react'
|
||||
import data from './WeaveIconBig.json'
|
||||
import IconBase from '@/app/components/base/icons/IconBase'
|
||||
import type { IconBaseProps, IconData } from '@/app/components/base/icons/IconBase'
|
||||
|
||||
const Icon = React.forwardRef<React.MutableRefObject<SVGElement>, Omit<IconBaseProps, 'data'>>((
|
||||
props,
|
||||
ref,
|
||||
) => <IconBase {...props} ref={ref} data={data as IconData} />)
|
||||
|
||||
Icon.displayName = 'WeaveIconBig'
|
||||
|
||||
export default Icon
|
||||
@ -5,3 +5,5 @@ export { default as LangsmithIcon } from './LangsmithIcon'
|
||||
export { default as OpikIconBig } from './OpikIconBig'
|
||||
export { default as OpikIcon } from './OpikIcon'
|
||||
export { default as TracingIcon } from './TracingIcon'
|
||||
export { default as WeaveIconBig } from './WeaveIconBig'
|
||||
export { default as WeaveIcon } from './WeaveIcon'
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import type { FC } from 'react'
|
||||
import { basePath } from '@/utils/var'
|
||||
|
||||
type LogoEmbeddedChatAvatarProps = {
|
||||
className?: string
|
||||
@ -8,7 +9,7 @@ const LogoEmbeddedChatAvatar: FC<LogoEmbeddedChatAvatarProps> = ({
|
||||
}) => {
|
||||
return (
|
||||
<img
|
||||
src='/logo/logo-embedded-chat-avatar.png'
|
||||
src={`${basePath}/logo/logo-embedded-chat-avatar.png`}
|
||||
className={`block h-10 w-10 ${className}`}
|
||||
alt='logo'
|
||||
/>
|
||||
|
||||
@ -22,10 +22,6 @@ export function preprocessMermaidCode(code: string): string {
|
||||
.replace(/section\s+([^:]+):/g, (match, sectionName) => `section ${sectionName}:`)
|
||||
// Fix common syntax issues
|
||||
.replace(/fifopacket/g, 'rect')
|
||||
// Ensure graph has direction
|
||||
.replace(/^graph\s+((?:TB|BT|RL|LR)*)/, (match, direction) => {
|
||||
return direction ? match : 'graph TD'
|
||||
})
|
||||
// Clean up empty lines and extra spaces
|
||||
.trim()
|
||||
}
|
||||
|
||||
@ -144,7 +144,7 @@ const WorkflowVariableBlockComponent = ({
|
||||
}
|
||||
|
||||
if (!node)
|
||||
return null
|
||||
return Item
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
|
||||
@ -68,7 +68,7 @@ const Toast = ({
|
||||
</div>
|
||||
<div className={`flex py-1 ${size === 'md' ? 'px-1' : 'px-0.5'} grow flex-col items-start gap-1`}>
|
||||
<div className='flex items-center gap-1'>
|
||||
<div className='system-sm-semibold text-text-primary'>{message}</div>
|
||||
<div className='system-sm-semibold text-text-primary [word-break:break-word]'>{message}</div>
|
||||
{customComponent}
|
||||
</div>
|
||||
{children && <div className='system-xs-regular text-text-secondary'>
|
||||
|
||||
Reference in New Issue
Block a user