Sfoglia il codice sorgente

Feat: Display file references for agent dialogues #3221 (#8854)

### What problem does this PR solve?

Feat: Display file references for agent dialogues #3221
### Type of change


- [x] New Feature (non-breaking change which adds functionality)
tags/v0.20.0
balibabu 3 mesi fa
parent
commit
faebb519f7
Nessun account collegato all'indirizzo email del committer

+ 71
- 0
web/src/components/next-markdown-content/index.less Vedi File

.markdownContentWrapper {
:global(section.think) {
padding-left: 10px;
color: #8b8b8b;
border-left: 2px solid #d5d3d3;
margin-bottom: 10px;
font-size: 12px;
}
:global(blockquote) {
padding-left: 10px;
border-left: 4px solid #ccc;
}
}

.referencePopoverWrapper {
max-width: 50vw;
}

.referenceChunkImage {
width: 10vw;
object-fit: contain;
}

.referenceInnerChunkImage {
display: block;
object-fit: contain;
max-width: 100%;
max-height: 6vh;
}

.referenceImagePreview {
max-width: 45vw;
max-height: 45vh;
}
.chunkContentText {
.chunkText;
max-height: 45vh;
overflow-y: auto;
}
.documentLink {
padding: 0;
}

.referenceIcon {
padding: 0 6px;
}

.cursor {
display: inline-block;
width: 1px;
height: 16px;
background-color: black;
animation: blink 0.6s infinite;
vertical-align: text-top;
@keyframes blink {
0% {
opacity: 1;
}
50% {
opacity: 0;
}
100% {
opacity: 1;
}
}
}

.fileThumbnail {
display: inline-block;
max-width: 40px;
}

+ 284
- 0
web/src/components/next-markdown-content/index.tsx Vedi File

import Image from '@/components/image';
import SvgIcon from '@/components/svg-icon';
import { IReferenceChunk, IReferenceObject } from '@/interfaces/database/chat';
import { getExtension } from '@/utils/document-util';
import DOMPurify from 'dompurify';
import { memo, useCallback, useEffect, useMemo } from 'react';
import Markdown from 'react-markdown';
import reactStringReplace from 'react-string-replace';
import SyntaxHighlighter from 'react-syntax-highlighter';
import rehypeKatex from 'rehype-katex';
import rehypeRaw from 'rehype-raw';
import remarkGfm from 'remark-gfm';
import remarkMath from 'remark-math';
import { visitParents } from 'unist-util-visit-parents';

import { useFetchDocumentThumbnailsByIds } from '@/hooks/document-hooks';
import { useTranslation } from 'react-i18next';

import 'katex/dist/katex.min.css'; // `rehype-katex` does not import the CSS for you

import {
preprocessLaTeX,
replaceThinkToSection,
showImage,
} from '@/utils/chat';

import { cn } from '@/lib/utils';
import { currentReg, replaceTextByOldReg } from '@/pages/chat/utils';
import classNames from 'classnames';
import { pipe } from 'lodash/fp';
import { CircleAlert } from 'lucide-react';
import { Button } from '../ui/button';
import {
HoverCard,
HoverCardContent,
HoverCardTrigger,
} from '../ui/hover-card';
import styles from './index.less';

const getChunkIndex = (match: string) => Number(match);
// TODO: The display of the table is inconsistent with the display previously placed in the MessageItem.
function MarkdownContent({
reference,
clickDocumentButton,
content,
}: {
content: string;
loading: boolean;
reference?: IReferenceObject;
clickDocumentButton?: (documentId: string, chunk: IReferenceChunk) => void;
}) {
const { t } = useTranslation();
const { setDocumentIds, data: fileThumbnails } =
useFetchDocumentThumbnailsByIds();
const contentWithCursor = useMemo(() => {
// let text = DOMPurify.sanitize(content);
let text = content;
if (text === '') {
text = t('chat.searching');
}
const nextText = replaceTextByOldReg(text);
return pipe(replaceThinkToSection, preprocessLaTeX)(nextText);
}, [content, t]);

useEffect(() => {
const docAggs = reference?.doc_aggs;
setDocumentIds(Array.isArray(docAggs) ? docAggs.map((x) => x.doc_id) : []);
}, [reference, setDocumentIds]);

const handleDocumentButtonClick = useCallback(
(
documentId: string,
chunk: IReferenceChunk,
isPdf: boolean,
documentUrl?: string,
) =>
() => {
if (!isPdf) {
if (!documentUrl) {
return;
}
window.open(documentUrl, '_blank');
} else {
clickDocumentButton?.(documentId, chunk);
}
},
[clickDocumentButton],
);

const rehypeWrapReference = () => {
return function wrapTextTransform(tree: any) {
visitParents(tree, 'text', (node, ancestors) => {
const latestAncestor = ancestors.at(-1);
if (
latestAncestor.tagName !== 'custom-typography' &&
latestAncestor.tagName !== 'code'
) {
node.type = 'element';
node.tagName = 'custom-typography';
node.properties = {};
node.children = [{ type: 'text', value: node.value }];
}
});
};
};

const getReferenceInfo = useCallback(
(chunkIndex: number) => {
const chunks = reference?.chunks ?? {};
const chunkItem = chunks[chunkIndex];

const documentList = Object.values(reference?.doc_aggs ?? {});
const document = documentList.find(
(x) => x?.doc_id === chunkItem?.document_id,
);
const documentId = document?.doc_id;
const documentUrl = document?.url;
const fileThumbnail = documentId ? fileThumbnails[documentId] : '';
const fileExtension = documentId ? getExtension(document?.doc_name) : '';
const imageId = chunkItem?.image_id;

return {
documentUrl,
fileThumbnail,
fileExtension,
imageId,
chunkItem,
documentId,
document,
};
},
[fileThumbnails, reference],
);

const renderPopoverContent = useCallback(
(chunkIndex: number) => {
const {
documentUrl,
fileThumbnail,
fileExtension,
imageId,
chunkItem,
documentId,
document,
} = getReferenceInfo(chunkIndex);

return (
<div key={chunkItem?.id} className="flex gap-2">
{imageId && (
<HoverCard>
<HoverCardTrigger>
<Image
id={imageId}
className={styles.referenceChunkImage}
></Image>
</HoverCardTrigger>
<HoverCardContent>
<Image
id={imageId}
className={cn(styles.referenceImagePreview)}
></Image>
</HoverCardContent>
</HoverCard>
)}
<div className={'space-y-2 max-w-[40vw] w-full'}>
<div
dangerouslySetInnerHTML={{
__html: DOMPurify.sanitize(chunkItem?.content ?? ''),
}}
className={classNames(styles.chunkContentText, 'w-full')}
></div>
{documentId && (
<div className="flex gap-1">
{fileThumbnail ? (
<img
src={fileThumbnail}
alt=""
className={styles.fileThumbnail}
/>
) : (
<SvgIcon
name={`file-icon/${fileExtension}`}
width={24}
></SvgIcon>
)}
<Button
variant="link"
onClick={handleDocumentButtonClick(
documentId,
chunkItem,
fileExtension === 'pdf',
documentUrl,
)}
className="text-ellipsis text-wrap"
>
{document?.doc_name}
</Button>
</div>
)}
</div>
</div>
);
},
[getReferenceInfo, handleDocumentButtonClick],
);

const renderReference = useCallback(
(text: string) => {
let replacedText = reactStringReplace(text, currentReg, (match, i) => {
const chunkIndex = getChunkIndex(match);

const { documentUrl, fileExtension, imageId, chunkItem, documentId } =
getReferenceInfo(chunkIndex);

const docType = chunkItem?.doc_type;

return showImage(docType) ? (
<Image
id={imageId}
className={styles.referenceInnerChunkImage}
onClick={
documentId
? handleDocumentButtonClick(
documentId,
chunkItem,
fileExtension === 'pdf',
documentUrl,
)
: () => {}
}
></Image>
) : (
<HoverCard key={i}>
<HoverCardTrigger>
<CircleAlert className="size-4 inline-block" />
</HoverCardTrigger>
<HoverCardContent>
{renderPopoverContent(chunkIndex)}
</HoverCardContent>
</HoverCard>
);
});

return replacedText;
},
[renderPopoverContent, getReferenceInfo, handleDocumentButtonClick],
);

return (
<Markdown
rehypePlugins={[rehypeWrapReference, rehypeKatex, rehypeRaw]}
remarkPlugins={[remarkGfm, remarkMath]}
className={styles.markdownContentWrapper}
components={
{
'custom-typography': ({ children }: { children: string }) =>
renderReference(children),
code(props: any) {
const { children, className, node, ...rest } = props;
const match = /language-(\w+)/.exec(className || '');
return match ? (
<SyntaxHighlighter
{...rest}
PreTag="div"
language={match[1]}
wrapLongLines
>
{String(children).replace(/\n$/, '')}
</SyntaxHighlighter>
) : (
<code {...rest} className={classNames(className, 'text-wrap')}>
{children}
</code>
);
},
} as any
}
>
{contentWithCursor}
</Markdown>
);
}

export default memo(MarkdownContent);

+ 0
- 2
web/src/components/next-message-item/index.less Vedi File

.messageTextDark { .messageTextDark {
.chunkText(); .chunkText();
.messageTextBase(); .messageTextBase();
background-color: #1668dc;
word-break: break-word; word-break: break-word;
:global(section.think) { :global(section.think) {
color: rgb(166, 166, 166); color: rgb(166, 166, 166);
.messageUserText { .messageUserText {
.chunkText(); .chunkText();
.messageTextBase(); .messageTextBase();
background-color: rgba(255, 255, 255, 0.3);
word-break: break-word; word-break: break-word;
text-align: justify; text-align: justify;
} }

+ 26
- 42
web/src/components/next-message-item/index.tsx Vedi File

import { ReactComponent as AssistantIcon } from '@/assets/svg/assistant.svg'; import { ReactComponent as AssistantIcon } from '@/assets/svg/assistant.svg';
import { MessageType } from '@/constants/chat'; import { MessageType } from '@/constants/chat';
import { useSetModalState } from '@/hooks/common-hooks'; import { useSetModalState } from '@/hooks/common-hooks';
import { IReference, IReferenceChunk } from '@/interfaces/database/chat';
import { IReferenceChunk, IReferenceObject } from '@/interfaces/database/chat';
import classNames from 'classnames'; import classNames from 'classnames';
import { import {
PropsWithChildren, PropsWithChildren,
useFetchDocumentThumbnailsByIds, useFetchDocumentThumbnailsByIds,
} from '@/hooks/document-hooks'; } from '@/hooks/document-hooks';
import { IRegenerateMessage, IRemoveMessageById } from '@/hooks/logic-hooks'; import { IRegenerateMessage, IRemoveMessageById } from '@/hooks/logic-hooks';
import { cn } from '@/lib/utils';
import { IMessage } from '@/pages/chat/interface'; import { IMessage } from '@/pages/chat/interface';
import MarkdownContent from '@/pages/chat/markdown-content';
import { getExtension, isImage } from '@/utils/document-util'; import { getExtension, isImage } from '@/utils/document-util';
import { Avatar, Button, Flex, List, Space, Typography } from 'antd'; import { Avatar, Button, Flex, List, Space, Typography } from 'antd';
import { isEmpty } from 'lodash';
import FileIcon from '../file-icon'; import FileIcon from '../file-icon';
import IndentedTreeModal from '../indented-tree/modal'; import IndentedTreeModal from '../indented-tree/modal';
import NewDocumentLink from '../new-document-link'; import NewDocumentLink from '../new-document-link';
import MarkdownContent from '../next-markdown-content';
import { useTheme } from '../theme-provider'; import { useTheme } from '../theme-provider';
import { AssistantGroupButton, UserGroupButton } from './group-button'; import { AssistantGroupButton, UserGroupButton } from './group-button';
import styles from './index.less'; import styles from './index.less';
import { ReferenceDocumentList } from './reference-document-list';


const { Text } = Typography; const { Text } = Typography;


IRegenerateMessage, IRegenerateMessage,
PropsWithChildren { PropsWithChildren {
item: IMessage; item: IMessage;
reference: IReference;
reference?: IReferenceObject;
loading?: boolean; loading?: boolean;
sendLoading?: boolean; sendLoading?: boolean;
visibleAvatar?: boolean; visibleAvatar?: boolean;
showLoudspeaker?: boolean; showLoudspeaker?: boolean;
} }


const MessageItem = ({
function MessageItem({
item, item,
reference, reference,
loading = false, loading = false,
avatarDialog, avatarDialog,
sendLoading = false, sendLoading = false,
clickDocumentButton, clickDocumentButton,
index,
removeMessageById, removeMessageById,
regenerateMessage, regenerateMessage,
showLikeButton = true, showLikeButton = true,
showLoudspeaker = true, showLoudspeaker = true,
visibleAvatar = true, visibleAvatar = true,
children, children,
}: IProps) => {
}: IProps) {
const { theme } = useTheme(); const { theme } = useTheme();
const isAssistant = item.role === MessageType.Assistant; const isAssistant = item.role === MessageType.Assistant;
const isUser = item.role === MessageType.User; const isUser = item.role === MessageType.User;
const { visible, hideModal, showModal } = useSetModalState(); const { visible, hideModal, showModal } = useSetModalState();
const [clickedDocumentId, setClickedDocumentId] = useState(''); const [clickedDocumentId, setClickedDocumentId] = useState('');


const referenceDocumentList = useMemo(() => {
return reference?.doc_aggs ?? [];
const referenceDocuments = useMemo(() => {
const docs = reference?.doc_aggs ?? {};

return Object.values(docs);
}, [reference?.doc_aggs]); }, [reference?.doc_aggs]);


const handleUserDocumentClick = useCallback( const handleUserDocumentClick = useCallback(
{/* <b>{isAssistant ? '' : nickname}</b> */} {/* <b>{isAssistant ? '' : nickname}</b> */}
</Space> </Space>
<div <div
className={
isAssistant
? theme === 'dark'
? styles.messageTextDark
: styles.messageText
: styles.messageUserText
}
className={cn({
[theme === 'dark'
? styles.messageTextDark
: styles.messageText]: isAssistant,
[styles.messageUserText]: !isAssistant,
'bg-background-card': !isAssistant,
})}
> >
{item.data ? ( {item.data ? (
children children
) : sendLoading && isEmpty(item.content) ? (
'searching...'
) : ( ) : (
<MarkdownContent <MarkdownContent
loading={loading} loading={loading}
></MarkdownContent> ></MarkdownContent>
)} )}
</div> </div>
{isAssistant && referenceDocumentList.length > 0 && (
<List
bordered
dataSource={referenceDocumentList}
renderItem={(item) => {
return (
<List.Item>
<Flex gap={'small'} align="center">
<FileIcon
id={item.doc_id}
name={item.doc_name}
></FileIcon>

<NewDocumentLink
documentId={item.doc_id}
documentName={item.doc_name}
prefix="document"
link={item.url}
>
{item.doc_name}
</NewDocumentLink>
</Flex>
</List.Item>
);
}}
/>
{isAssistant && referenceDocuments.length > 0 && (
<ReferenceDocumentList
list={referenceDocuments}
></ReferenceDocumentList>
)} )}
{isUser && documentList.length > 0 && ( {isUser && documentList.length > 0 && (
<List <List
)} )}
</div> </div>
); );
};
}


export default memo(MessageItem); export default memo(MessageItem);

+ 27
- 0
web/src/components/next-message-item/reference-document-list.tsx Vedi File

import { Card, CardContent } from '@/components/ui/card';
import { Docagg } from '@/interfaces/database/chat';
import FileIcon from '../file-icon';
import NewDocumentLink from '../new-document-link';

export function ReferenceDocumentList({ list }: { list: Docagg[] }) {
return (
<section className="flex gap-3 flex-wrap">
{list.map((item) => (
<Card key={item.doc_id}>
<CardContent className="p-2">
<FileIcon id={item.doc_id} name={item.doc_name}></FileIcon>
<NewDocumentLink
documentId={item.doc_id}
documentName={item.doc_name}
prefix="document"
link={item.url}
className="text-text-sub-title-invert"
>
{item.doc_name}
</NewDocumentLink>
</CardContent>
</Card>
))}
</section>
);
}

+ 8
- 1
web/src/hooks/use-send-message.ts Vedi File

import { Authorization } from '@/constants/authorization'; import { Authorization } from '@/constants/authorization';
import { IReferenceObject } from '@/interfaces/database/chat';
import { BeginQuery } from '@/pages/agent/interface'; import { BeginQuery } from '@/pages/agent/interface';
import api from '@/utils/api'; import api from '@/utils/api';
import { getAuthorization } from '@/utils/authorization-util'; import { getAuthorization } from '@/utils/authorization-util';
content: string; content: string;
} }


export interface IMessageEndData {
reference: IReferenceObject;
}

export interface ILogData extends INodeData { export interface ILogData extends INodeData {
logs: { logs: {
name: string; name: string;


export type IMessageEvent = IAnswerEvent<IMessageData>; export type IMessageEvent = IAnswerEvent<IMessageData>;


export type IMessageEndEvent = IAnswerEvent<IMessageEndData>;

export type IInputEvent = IAnswerEvent<IInputData>; export type IInputEvent = IAnswerEvent<IInputData>;


export type ILogEvent = IAnswerEvent<ILogData>; export type ILogEvent = IAnswerEvent<ILogData>;


export type IChatEvent = INodeEvent | IMessageEvent;
export type IChatEvent = INodeEvent | IMessageEvent | IMessageEndEvent;


export type IEventList = Array<IChatEvent>; export type IEventList = Array<IChatEvent>;



+ 5
- 0
web/src/interfaces/database/chat.ts Vedi File

total: number; total: number;
} }


export interface IReferenceObject {
chunks: Record<string, IReferenceChunk>;
doc_aggs: Record<string, Docagg>;
}

export interface IAnswer { export interface IAnswer {
answer: string; answer: string;
reference?: IReference; reference?: IReference;

+ 3
- 7
web/src/pages/agent/chat/box.tsx Vedi File

import DebugContent from '../debug-content'; import DebugContent from '../debug-content';
import { BeginQuery } from '../interface'; import { BeginQuery } from '../interface';
import { buildBeginQueryWithObject } from '../utils'; import { buildBeginQueryWithObject } from '../utils';
import { buildAgentMessageItemReference } from '../utils/chat';


const AgentChatBox = () => { const AgentChatBox = () => {
const { const {
loading, loading,
ref, ref,
derivedMessages, derivedMessages,
reference,
stopOutputMessage, stopOutputMessage,
sendFormMessage, sendFormMessage,
findReferenceByMessageId,
} = useSendNextMessage(); } = useSendNextMessage();


const { visible, hideModal, documentId, selectedChunk, clickDocumentButton } = const { visible, hideModal, documentId, selectedChunk, clickDocumentButton } =


return ( return (
<> <>
<section className="flex flex-1 flex-col pl-5 h-[90vh]">
<section className="flex flex-1 flex-col px-5 h-[90vh]">
<div className="flex-1 overflow-auto"> <div className="flex-1 overflow-auto">
<div> <div>
<Spin spinning={loading}> <Spin spinning={loading}>
avatar={userInfo.avatar} avatar={userInfo.avatar}
avatarDialog={canvasInfo.avatar} avatarDialog={canvasInfo.avatar}
item={message} item={message}
reference={buildAgentMessageItemReference(
{ message: derivedMessages, reference },
message,
)}
reference={findReferenceByMessageId(message.id)}
clickDocumentButton={clickDocumentButton} clickDocumentButton={clickDocumentButton}
index={i} index={i}
showLikeButton={false} showLikeButton={false}

+ 4
- 10
web/src/pages/agent/chat/chat-sheet.tsx Vedi File

import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
} from '@/components/ui/sheet';
import { Sheet, SheetContent } from '@/components/ui/sheet';
import { IModalProps } from '@/interfaces/common'; import { IModalProps } from '@/interfaces/common';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useTranslation } from 'react-i18next';
import AgentChatBox from './box'; import AgentChatBox from './box';


export function ChatSheet({ hideModal }: IModalProps<any>) { export function ChatSheet({ hideModal }: IModalProps<any>) {
const { t } = useTranslation();
return ( return (
<Sheet open modal={false} onOpenChange={hideModal}> <Sheet open modal={false} onOpenChange={hideModal}>
<SheetTitle className="hidden"></SheetTitle>
<SheetContent <SheetContent
className={cn('top-20 p-0')} className={cn('top-20 p-0')}
onInteractOutside={(e) => e.preventDefault()} onInteractOutside={(e) => e.preventDefault()}
> >
<SheetHeader>
<SheetTitle>Are you absolutely sure?</SheetTitle>
</SheetHeader>
<div className="pl-5 pt-2">{t('chat.chat')}</div>
<AgentChatBox></AgentChatBox> <AgentChatBox></AgentChatBox>
</SheetContent> </SheetContent>
</Sheet> </Sheet>

+ 49
- 7
web/src/pages/agent/chat/hooks.ts Vedi File

import { import {
IEventList, IEventList,
IInputEvent, IInputEvent,
IMessageEndData,
IMessageEndEvent,
IMessageEvent, IMessageEvent,
MessageEventType, MessageEventType,
useSendMessageBySSE, useSendMessageBySSE,
import api from '@/utils/api'; import api from '@/utils/api';
import { get } from 'lodash'; import { get } from 'lodash';
import trim from 'lodash/trim'; import trim from 'lodash/trim';
import { useCallback, useContext, useEffect, useMemo } from 'react';
import { useCallback, useContext, useEffect, useMemo, useState } from 'react';
import { useParams } from 'umi'; import { useParams } from 'umi';
import { v4 as uuid } from 'uuid'; import { v4 as uuid } from 'uuid';
import { BeginId } from '../constant'; import { BeginId } from '../constant';
const { refetch } = useFetchAgent(); const { refetch } = useFetchAgent();
const { addEventList } = useContext(AgentChatLogContext); const { addEventList } = useContext(AgentChatLogContext);
const getBeginNodeDataQuery = useGetBeginNodeDataQuery(); const getBeginNodeDataQuery = useGetBeginNodeDataQuery();
const [messageEndEventList, setMessageEndEventList] = useState<
IMessageEndEvent[]
>([]);


const { send, answerList, done, stopOutputMessage } = useSendMessageBySSE( const { send, answerList, done, stopOutputMessage } = useSendMessageBySSE(
api.runCanvas, api.runCanvas,
const params: Record<string, unknown> = { const params: Record<string, unknown> = {
id: agentId, id: agentId,
}; };

params.running_hint_text = i18n.t('flow.runningHintText', { params.running_hint_text = i18n.t('flow.runningHintText', {
defaultValue: 'is running...🕞', defaultValue: 'is running...🕞',
}); });
if (message.content) { if (message.content) {
const query = getBeginNodeDataQuery();

params.query = message.content; params.query = message.content;
// params.message_id = message.id; // params.message_id = message.id;
params.inputs = {}; // begin operator inputs
params.inputs = transferInputsArrayToObject(query); // begin operator inputs
} }
const res = await send(params); const res = await send(params);


refetch(); // pull the message list after sending the message successfully refetch(); // pull the message list after sending the message successfully
} }
}, },
[agentId, send, setValue, removeLatestMessage, refetch],
[
agentId,
send,
getBeginNodeDataQuery,
setValue,
removeLatestMessage,
refetch,
],
); );


const handleSendMessage = useCallback( const handleSendMessage = useCallback(
[sendMessage], [sendMessage],
); );


useEffect(() => {
const messageEndEvent = answerList.find(
(x) => x.event === MessageEventType.MessageEnd,
);
if (messageEndEvent) {
setMessageEndEventList((list) => {
const nextList = [...list];
if (
nextList.every((x) => x.message_id !== messageEndEvent.message_id)
) {
nextList.push(messageEndEvent as IMessageEndEvent);
}
return nextList;
});
}
}, [addEventList.length, answerList]);

useEffect(() => { useEffect(() => {
const { content, id } = findMessageFromList(answerList); const { content, id } = findMessageFromList(answerList);
const inputAnswer = findInputFromList(answerList); const inputAnswer = findInputFromList(answerList);
[addNewestOneQuestion, send], [addNewestOneQuestion, send],
); );


const findReferenceByMessageId = useCallback(
(messageId: string) => {
const event = messageEndEventList.find(
(item) => item.message_id === messageId,
);
if (event) {
return (event?.data as IMessageEndData)?.reference;
}
},
[messageEndEventList],
);

useEffect(() => { useEffect(() => {
const query = getBeginNodeDataQuery();
if (query.length > 0) {
send({ id: agentId, inputs: transferInputsArrayToObject(query) });
} else if (prologue) {
if (prologue) {
addNewestOneAnswer({ addNewestOneAnswer({
answer: prologue, answer: prologue,
}); });
stopOutputMessage, stopOutputMessage,
send, send,
sendFormMessage, sendFormMessage,
findReferenceByMessageId,
}; };
}; };

Loading…
Annulla
Salva