Agent chat file drag and drop (#13226)
- Added drag and drop support - The AIChatTab component has been refactored into smaller, more focused components to improve readability, maintainability, and scalability of the codebase. - Introduced a custom useAIChatFileUpload hook to encapsulate and manage file upload logic, promoting code reuse and separation of concerns. ### Demo https://github.com/user-attachments/assets/c4b2a67a-2736-48ae-9ba8-8e124e4b6069 --------- Co-authored-by: Raphaël Bosi <71827178+bosiraphael@users.noreply.github.com> Co-authored-by: neo773 <62795688+neo773@users.noreply.github.com> Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> Co-authored-by: Félix Malfait <felix.malfait@gmail.com> Co-authored-by: Félix Malfait <felix@twenty.com> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: github-actions <github-actions@twenty.com> Co-authored-by: MD Readul Islam <99027968+readul-islam@users.noreply.github.com> Co-authored-by: readul-islam <developer.readul@gamil.com> Co-authored-by: Thomas des Francs <tdesfrancs@gmail.com> Co-authored-by: Guillim <guillim@users.noreply.github.com> Co-authored-by: Lucas Bordeau <bordeau.lucas@gmail.com> Co-authored-by: Jean-Baptiste Ronssin <65334819+jbronssin@users.noreply.github.com> Co-authored-by: kahkashan shaik <93042682+kahkashanshaik@users.noreply.github.com> Co-authored-by: martmull <martmull@hotmail.fr> Co-authored-by: Paul Rastoin <45004772+prastoin@users.noreply.github.com> Co-authored-by: bosiraphael <raphael.bosi@gmail.com> Co-authored-by: Naifer <161821705+omarNaifer12@users.noreply.github.com> Co-authored-by: Marie Stoppa <marie.stoppa@essec.edu>
This commit is contained in:
@ -0,0 +1,51 @@
|
|||||||
|
import { useTheme } from '@emotion/react';
|
||||||
|
import styled from '@emotion/styled';
|
||||||
|
import { t } from '@lingui/core/macro';
|
||||||
|
import { IconSparkles } from 'twenty-ui/display';
|
||||||
|
const StyledEmptyState = styled.div`
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex: 1;
|
||||||
|
gap: ${({ theme }) => theme.spacing(2)};
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100%;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const StyledSparkleIcon = styled.div`
|
||||||
|
align-items: center;
|
||||||
|
background: ${({ theme }) => theme.background.transparent.blue};
|
||||||
|
border-radius: ${({ theme }) => theme.border.radius.sm};
|
||||||
|
padding: ${({ theme }) => theme.spacing(2.5)};
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
margin-bottom: ${({ theme }) => theme.spacing(2)};
|
||||||
|
`;
|
||||||
|
|
||||||
|
const StyledTitle = styled.div`
|
||||||
|
font-size: ${({ theme }) => theme.font.size.lg};
|
||||||
|
font-weight: 600;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const StyledDescription = styled.div`
|
||||||
|
color: ${({ theme }) => theme.font.color.secondary};
|
||||||
|
text-align: center;
|
||||||
|
max-width: 85%;
|
||||||
|
font-size: ${({ theme }) => theme.font.size.md};
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const AIChatEmptyState = () => {
|
||||||
|
const theme = useTheme();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<StyledEmptyState>
|
||||||
|
<StyledSparkleIcon>
|
||||||
|
<IconSparkles size={theme.icon.size.lg} color={theme.color.blue} />
|
||||||
|
</StyledSparkleIcon>
|
||||||
|
<StyledTitle>{t`Chat`}</StyledTitle>
|
||||||
|
<StyledDescription>
|
||||||
|
{t`Start a conversation with your AI agent to get workflow insights, task assistance, and process guidance`}
|
||||||
|
</StyledDescription>
|
||||||
|
</StyledEmptyState>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,204 @@
|
|||||||
|
import { keyframes, useTheme } from '@emotion/react';
|
||||||
|
import styled from '@emotion/styled';
|
||||||
|
import { Avatar, IconDotsVertical, IconSparkles } from 'twenty-ui/display';
|
||||||
|
|
||||||
|
import { LightCopyIconButton } from '@/object-record/record-field/components/LightCopyIconButton';
|
||||||
|
import { AgentChatFilePreview } from '@/workflow/workflow-steps/workflow-actions/ai-agent-action/components/AgentChatFilePreview';
|
||||||
|
import { AgentChatMessageRole } from '@/workflow/workflow-steps/workflow-actions/ai-agent-action/constants/agent-chat-message-role';
|
||||||
|
|
||||||
|
import { AgentChatMessage } from '~/generated/graphql';
|
||||||
|
import { beautifyPastDateRelativeToNow } from '~/utils/date-utils';
|
||||||
|
|
||||||
|
const StyledMessageBubble = styled.div<{ isUser?: boolean }>`
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
&:hover .message-footer {
|
||||||
|
opacity: 1;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const StyledMessageRow = styled.div<{ isShowingToolCall?: boolean }>`
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: ${({ isShowingToolCall }) =>
|
||||||
|
isShowingToolCall ? 'center' : 'flex-start'};
|
||||||
|
gap: ${({ theme }) => theme.spacing(3)};
|
||||||
|
width: 100%;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const StyledMessageText = styled.div<{ isUser?: boolean }>`
|
||||||
|
background: ${({ theme, isUser }) =>
|
||||||
|
isUser ? theme.background.secondary : theme.background.transparent};
|
||||||
|
border-radius: ${({ theme }) => theme.border.radius.md};
|
||||||
|
padding: ${({ theme, isUser }) => (isUser ? theme.spacing(1, 2) : 0)};
|
||||||
|
border: ${({ isUser, theme }) =>
|
||||||
|
!isUser ? 'none' : `1px solid ${theme.border.color.light}`};
|
||||||
|
color: ${({ theme, isUser }) =>
|
||||||
|
isUser ? theme.font.color.light : theme.font.color.primary};
|
||||||
|
font-weight: ${({ isUser }) => (isUser ? 500 : 400)};
|
||||||
|
width: fit-content;
|
||||||
|
white-space: pre-line;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const StyledMessageFooter = styled.div`
|
||||||
|
align-items: center;
|
||||||
|
color: ${({ theme }) => theme.font.color.secondary};
|
||||||
|
display: flex;
|
||||||
|
font-size: ${({ theme }) => theme.font.size.sm};
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-top: ${({ theme }) => theme.spacing(1)};
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
transition: opacity 0.3s ease-in-out;
|
||||||
|
width: 100%;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const StyledAvatarContainer = styled.div<{ isUser?: boolean }>`
|
||||||
|
align-items: center;
|
||||||
|
background: ${({ theme, isUser }) =>
|
||||||
|
isUser
|
||||||
|
? theme.background.transparent.light
|
||||||
|
: theme.background.transparent.blue};
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
height: 24px;
|
||||||
|
min-width: 24px;
|
||||||
|
border-radius: ${({ theme }) => theme.border.radius.sm};
|
||||||
|
padding: 1px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const StyledMessageContainer = styled.div`
|
||||||
|
width: 100%;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const StyledFilesContainer = styled.div`
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
gap: ${({ theme }) => theme.spacing(2)};
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-top: ${({ theme }) => theme.spacing(2)};
|
||||||
|
`;
|
||||||
|
|
||||||
|
const dots = keyframes`
|
||||||
|
0% { content: ''; }
|
||||||
|
33% { content: '.'; }
|
||||||
|
66% { content: '..'; }
|
||||||
|
100% { content: '...'; }
|
||||||
|
`;
|
||||||
|
|
||||||
|
const StyledToolCallContainer = styled.div`
|
||||||
|
&::after {
|
||||||
|
display: inline-block;
|
||||||
|
content: '';
|
||||||
|
animation: ${dots} 750ms steps(3, end) infinite;
|
||||||
|
width: 2ch;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const StyledDotsIconContainer = styled.div`
|
||||||
|
align-items: center;
|
||||||
|
border: ${({ theme }) => `1px solid ${theme.border.color.light}`};
|
||||||
|
border-radius: ${({ theme }) => theme.border.radius.md};
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding-inline: ${({ theme }) => theme.spacing(1)};
|
||||||
|
`;
|
||||||
|
|
||||||
|
const StyledDotsIcon = styled(IconDotsVertical)`
|
||||||
|
color: ${({ theme }) => theme.font.color.light};
|
||||||
|
transform: rotate(90deg);
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const AIChatMessage = ({
|
||||||
|
message,
|
||||||
|
agentStreamingMessage,
|
||||||
|
}: {
|
||||||
|
message: AgentChatMessage;
|
||||||
|
agentStreamingMessage: { streamingText: string; toolCall: string };
|
||||||
|
}) => {
|
||||||
|
const theme = useTheme();
|
||||||
|
|
||||||
|
const getAssistantMessageContent = (message: AgentChatMessage) => {
|
||||||
|
if (message.content !== '') {
|
||||||
|
return message.content;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (agentStreamingMessage.streamingText !== '') {
|
||||||
|
return agentStreamingMessage.streamingText;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (agentStreamingMessage.toolCall !== '') {
|
||||||
|
return (
|
||||||
|
<StyledToolCallContainer>
|
||||||
|
{agentStreamingMessage.toolCall}
|
||||||
|
</StyledToolCallContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<StyledDotsIconContainer>
|
||||||
|
<StyledDotsIcon size={theme.icon.size.xl} />
|
||||||
|
</StyledDotsIconContainer>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<StyledMessageBubble
|
||||||
|
key={message.id}
|
||||||
|
isUser={message.role === AgentChatMessageRole.USER}
|
||||||
|
>
|
||||||
|
<StyledMessageRow
|
||||||
|
isShowingToolCall={
|
||||||
|
message.role === AgentChatMessageRole.ASSISTANT &&
|
||||||
|
message.content === '' &&
|
||||||
|
agentStreamingMessage.streamingText === '' &&
|
||||||
|
agentStreamingMessage.toolCall !== ''
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{message.role === AgentChatMessageRole.ASSISTANT && (
|
||||||
|
<StyledAvatarContainer>
|
||||||
|
<Avatar
|
||||||
|
size="sm"
|
||||||
|
placeholder="AI"
|
||||||
|
Icon={IconSparkles}
|
||||||
|
iconColor={theme.color.blue}
|
||||||
|
/>
|
||||||
|
</StyledAvatarContainer>
|
||||||
|
)}
|
||||||
|
{message.role === AgentChatMessageRole.USER && (
|
||||||
|
<StyledAvatarContainer isUser>
|
||||||
|
<Avatar size="sm" placeholder="U" type="rounded" />
|
||||||
|
</StyledAvatarContainer>
|
||||||
|
)}
|
||||||
|
<StyledMessageContainer>
|
||||||
|
<StyledMessageText
|
||||||
|
isUser={message.role === AgentChatMessageRole.USER}
|
||||||
|
>
|
||||||
|
{message.role === AgentChatMessageRole.ASSISTANT
|
||||||
|
? getAssistantMessageContent(message)
|
||||||
|
: message.content}
|
||||||
|
</StyledMessageText>
|
||||||
|
{message.files.length > 0 && (
|
||||||
|
<StyledFilesContainer>
|
||||||
|
{message.files.map((file) => (
|
||||||
|
<AgentChatFilePreview key={file.id} file={file} />
|
||||||
|
))}
|
||||||
|
</StyledFilesContainer>
|
||||||
|
)}
|
||||||
|
{message.content && (
|
||||||
|
<StyledMessageFooter className="message-footer">
|
||||||
|
<span>{beautifyPastDateRelativeToNow(message.createdAt)}</span>
|
||||||
|
<LightCopyIconButton copyText={message.content} />
|
||||||
|
</StyledMessageFooter>
|
||||||
|
)}
|
||||||
|
</StyledMessageContainer>
|
||||||
|
</StyledMessageRow>
|
||||||
|
</StyledMessageBubble>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,82 @@
|
|||||||
|
import { useApolloCoreClient } from '@/object-metadata/hooks/useApolloCoreClient';
|
||||||
|
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
|
||||||
|
import { useRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentStateV2';
|
||||||
|
import { agentChatSelectedFilesComponentState } from '@/workflow/workflow-steps/workflow-actions/ai-agent-action/states/agentChatSelectedFilesComponentState';
|
||||||
|
import { agentChatUploadedFilesComponentState } from '@/workflow/workflow-steps/workflow-actions/ai-agent-action/states/agentChatUploadedFilesComponentState';
|
||||||
|
import { useLingui } from '@lingui/react/macro';
|
||||||
|
import { isDefined } from 'twenty-shared/utils';
|
||||||
|
import {
|
||||||
|
File as FileDocument,
|
||||||
|
useCreateFileMutation,
|
||||||
|
} from '~/generated-metadata/graphql';
|
||||||
|
|
||||||
|
export const useAIChatFileUpload = ({ agentId }: { agentId: string }) => {
|
||||||
|
const coreClient = useApolloCoreClient();
|
||||||
|
const [createFile] = useCreateFileMutation({ client: coreClient });
|
||||||
|
const { t } = useLingui();
|
||||||
|
const { enqueueErrorSnackBar } = useSnackBar();
|
||||||
|
const [agentChatSelectedFiles, setAgentChatSelectedFiles] =
|
||||||
|
useRecoilComponentStateV2(agentChatSelectedFilesComponentState, agentId);
|
||||||
|
const [agentChatUploadedFiles, setAgentChatUploadedFiles] =
|
||||||
|
useRecoilComponentStateV2(agentChatUploadedFilesComponentState, agentId);
|
||||||
|
|
||||||
|
const sendFile = async (file: File) => {
|
||||||
|
try {
|
||||||
|
const result = await createFile({
|
||||||
|
variables: {
|
||||||
|
file,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const uploadedFile = result?.data?.createFile;
|
||||||
|
|
||||||
|
if (!isDefined(uploadedFile)) {
|
||||||
|
throw new Error(t`Couldn't upload the file.`);
|
||||||
|
}
|
||||||
|
setAgentChatSelectedFiles(
|
||||||
|
agentChatSelectedFiles.filter((f) => f.name !== file.name),
|
||||||
|
);
|
||||||
|
return uploadedFile;
|
||||||
|
} catch (error) {
|
||||||
|
const fileName = file.name;
|
||||||
|
enqueueErrorSnackBar({
|
||||||
|
message: t`Failed to upload file: ${fileName}`,
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const uploadFiles = async (files: File[]) => {
|
||||||
|
const uploadResults = await Promise.allSettled(
|
||||||
|
files.map((file) => sendFile(file)),
|
||||||
|
);
|
||||||
|
|
||||||
|
const successfulUploads = uploadResults.reduce<FileDocument[]>(
|
||||||
|
(acc, result) => {
|
||||||
|
if (result.status === 'fulfilled' && isDefined(result.value)) {
|
||||||
|
acc.push(result.value);
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (successfulUploads.length > 0) {
|
||||||
|
setAgentChatUploadedFiles([
|
||||||
|
...agentChatUploadedFiles,
|
||||||
|
...successfulUploads,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const failedCount = uploadResults.filter(
|
||||||
|
(result) => result.status === 'rejected',
|
||||||
|
).length;
|
||||||
|
if (failedCount > 0) {
|
||||||
|
enqueueErrorSnackBar({
|
||||||
|
message: t`${failedCount} file(s) failed to upload`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return { uploadFiles };
|
||||||
|
};
|
||||||
@ -0,0 +1,39 @@
|
|||||||
|
import { AgentChatThread } from '~/generated-metadata/graphql';
|
||||||
|
import { groupThreadsByDate } from '../groupThreadsByDate';
|
||||||
|
|
||||||
|
describe('groupThreadsByDate', () => {
|
||||||
|
const baseThread: Omit<AgentChatThread, 'createdAt' | 'id'> = {
|
||||||
|
agentId: 'agent-1',
|
||||||
|
title: 'Test Thread',
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const today = new Date();
|
||||||
|
const yesterday = new Date(today);
|
||||||
|
yesterday.setDate(today.getDate() - 1);
|
||||||
|
const twoDaysAgo = new Date(today);
|
||||||
|
twoDaysAgo.setDate(today.getDate() - 2);
|
||||||
|
|
||||||
|
const threads: AgentChatThread[] = [
|
||||||
|
{ ...baseThread, id: '1', createdAt: today.toISOString() },
|
||||||
|
{ ...baseThread, id: '2', createdAt: yesterday.toISOString() },
|
||||||
|
{ ...baseThread, id: '3', createdAt: twoDaysAgo.toISOString() },
|
||||||
|
];
|
||||||
|
|
||||||
|
it('groups threads into today, yesterday, and older', () => {
|
||||||
|
const result = groupThreadsByDate(threads);
|
||||||
|
expect(result.today).toHaveLength(1);
|
||||||
|
expect(result.today[0].id).toBe('1');
|
||||||
|
expect(result.yesterday).toHaveLength(1);
|
||||||
|
expect(result.yesterday[0].id).toBe('2');
|
||||||
|
expect(result.older).toHaveLength(1);
|
||||||
|
expect(result.older[0].id).toBe('3');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty arrays if no threads', () => {
|
||||||
|
const result = groupThreadsByDate([]);
|
||||||
|
expect(result.today).toEqual([]);
|
||||||
|
expect(result.yesterday).toEqual([]);
|
||||||
|
expect(result.older).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -1,77 +1,39 @@
|
|||||||
import { TextArea } from '@/ui/input/components/TextArea';
|
import { TextArea } from '@/ui/input/components/TextArea';
|
||||||
import { keyframes, useTheme } from '@emotion/react';
|
|
||||||
import styled from '@emotion/styled';
|
import styled from '@emotion/styled';
|
||||||
import {
|
import { IconHistory, IconMessageCirclePlus } from 'twenty-ui/display';
|
||||||
Avatar,
|
|
||||||
IconDotsVertical,
|
|
||||||
IconHistory,
|
|
||||||
IconMessageCirclePlus,
|
|
||||||
IconSparkles,
|
|
||||||
} from 'twenty-ui/display';
|
|
||||||
|
|
||||||
|
import { DropZone } from '@/activities/files/components/DropZone';
|
||||||
import { useCreateNewAIChatThread } from '@/ai/hooks/useCreateNewAIChatThread';
|
import { useCreateNewAIChatThread } from '@/ai/hooks/useCreateNewAIChatThread';
|
||||||
import { useCommandMenu } from '@/command-menu/hooks/useCommandMenu';
|
import { useCommandMenu } from '@/command-menu/hooks/useCommandMenu';
|
||||||
import { CommandMenuPages } from '@/command-menu/types/CommandMenuPages';
|
import { CommandMenuPages } from '@/command-menu/types/CommandMenuPages';
|
||||||
import { LightCopyIconButton } from '@/object-record/record-field/components/LightCopyIconButton';
|
|
||||||
import { ScrollWrapper } from '@/ui/utilities/scroll/components/ScrollWrapper';
|
import { ScrollWrapper } from '@/ui/utilities/scroll/components/ScrollWrapper';
|
||||||
import { AgentChatFilePreview } from '@/workflow/workflow-steps/workflow-actions/ai-agent-action/components/AgentChatFilePreview';
|
|
||||||
import { AgentChatFileUpload } from '@/workflow/workflow-steps/workflow-actions/ai-agent-action/components/AgentChatFileUpload';
|
import { AgentChatFileUpload } from '@/workflow/workflow-steps/workflow-actions/ai-agent-action/components/AgentChatFileUpload';
|
||||||
import { AgentChatMessageRole } from '@/workflow/workflow-steps/workflow-actions/ai-agent-action/constants/agent-chat-message-role';
|
|
||||||
|
|
||||||
|
import { AIChatEmptyState } from '@/ai/components/AIChatEmptyState';
|
||||||
|
import { AIChatMessage } from '@/ai/components/AIChatMessage';
|
||||||
|
import { useAIChatFileUpload } from '@/ai/hooks/useAIChatFileUpload';
|
||||||
import { t } from '@lingui/core/macro';
|
import { t } from '@lingui/core/macro';
|
||||||
|
import { useState } from 'react';
|
||||||
import { Button } from 'twenty-ui/input';
|
import { Button } from 'twenty-ui/input';
|
||||||
import { AgentChatMessage } from '~/generated/graphql';
|
|
||||||
import { beautifyPastDateRelativeToNow } from '~/utils/date-utils';
|
|
||||||
import { useAgentChat } from '../hooks/useAgentChat';
|
import { useAgentChat } from '../hooks/useAgentChat';
|
||||||
import { AIChatSkeletonLoader } from './AIChatSkeletonLoader';
|
import { AIChatSkeletonLoader } from './AIChatSkeletonLoader';
|
||||||
import { AgentChatSelectedFilesPreview } from './AgentChatSelectedFilesPreview';
|
import { AgentChatSelectedFilesPreview } from './AgentChatSelectedFilesPreview';
|
||||||
|
|
||||||
const StyledContainer = styled.div`
|
const StyledContainer = styled.div<{ isDraggingFile: boolean }>`
|
||||||
background: ${({ theme }) => theme.background.primary};
|
background: ${({ theme }) => theme.background.primary};
|
||||||
height: calc(100% - 154px);
|
height: ${({ isDraggingFile }) =>
|
||||||
`;
|
isDraggingFile ? `calc(100% - 24px)` : '100%'};
|
||||||
|
padding: ${({ isDraggingFile, theme }) =>
|
||||||
const StyledEmptyState = styled.div`
|
isDraggingFile ? theme.spacing(3) : '0'};
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
flex: 1;
|
|
||||||
gap: ${({ theme }) => theme.spacing(2)};
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
height: 100%;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const StyledSparkleIcon = styled.div`
|
|
||||||
align-items: center;
|
|
||||||
background: ${({ theme }) => theme.background.transparent.blue};
|
|
||||||
border-radius: ${({ theme }) => theme.border.radius.sm};
|
|
||||||
display: flex;
|
|
||||||
height: 40px;
|
|
||||||
justify-content: center;
|
|
||||||
margin-bottom: ${({ theme }) => theme.spacing(2)};
|
|
||||||
width: 40px;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const StyledTitle = styled.div`
|
|
||||||
font-size: ${({ theme }) => theme.font.size.lg};
|
|
||||||
font-weight: 600;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const StyledDescription = styled.div`
|
|
||||||
color: ${({ theme }) => theme.font.color.secondary};
|
|
||||||
text-align: center;
|
|
||||||
max-width: 85%;
|
|
||||||
font-size: ${({ theme }) => theme.font.size.md};
|
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const StyledInputArea = styled.div`
|
const StyledInputArea = styled.div`
|
||||||
align-items: flex-end;
|
align-items: flex-end;
|
||||||
bottom: 0;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: ${({ theme }) => theme.spacing(2)};
|
gap: ${({ theme }) => theme.spacing(2)};
|
||||||
position: absolute;
|
|
||||||
width: calc(100% - 24px);
|
|
||||||
padding: ${({ theme }) => theme.spacing(3)};
|
padding: ${({ theme }) => theme.spacing(3)};
|
||||||
background: ${({ theme }) => theme.background.primary};
|
background: ${({ theme }) => theme.background.primary};
|
||||||
`;
|
`;
|
||||||
@ -86,118 +48,12 @@ const StyledScrollWrapper = styled(ScrollWrapper)`
|
|||||||
width: calc(100% - 24px);
|
width: calc(100% - 24px);
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const StyledMessageBubble = styled.div<{ isUser?: boolean }>`
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: flex-start;
|
|
||||||
position: relative;
|
|
||||||
width: 100%;
|
|
||||||
|
|
||||||
&:hover .message-footer {
|
|
||||||
opacity: 1;
|
|
||||||
pointer-events: auto;
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
const StyledMessageRow = styled.div<{ isShowingToolCall?: boolean }>`
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
align-items: ${({ isShowingToolCall }) =>
|
|
||||||
isShowingToolCall ? 'center' : 'flex-start'};
|
|
||||||
gap: ${({ theme }) => theme.spacing(3)};
|
|
||||||
width: 100%;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const StyledMessageText = styled.div<{ isUser?: boolean }>`
|
|
||||||
background: ${({ theme, isUser }) =>
|
|
||||||
isUser ? theme.background.secondary : theme.background.transparent};
|
|
||||||
border-radius: ${({ theme }) => theme.border.radius.md};
|
|
||||||
padding: ${({ theme, isUser }) => (isUser ? theme.spacing(1, 2) : 0)};
|
|
||||||
border: ${({ isUser, theme }) =>
|
|
||||||
!isUser ? 'none' : `1px solid ${theme.border.color.light}`};
|
|
||||||
color: ${({ theme, isUser }) =>
|
|
||||||
isUser ? theme.font.color.light : theme.font.color.primary};
|
|
||||||
font-weight: ${({ isUser }) => (isUser ? 500 : 400)};
|
|
||||||
width: fit-content;
|
|
||||||
white-space: pre-line;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const StyledMessageFooter = styled.div`
|
|
||||||
align-items: center;
|
|
||||||
color: ${({ theme }) => theme.font.color.secondary};
|
|
||||||
display: flex;
|
|
||||||
font-size: ${({ theme }) => theme.font.size.sm};
|
|
||||||
justify-content: space-between;
|
|
||||||
margin-top: ${({ theme }) => theme.spacing(1)};
|
|
||||||
opacity: 0;
|
|
||||||
pointer-events: none;
|
|
||||||
transition: opacity 0.3s ease-in-out;
|
|
||||||
width: 100%;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const StyledAvatarContainer = styled.div<{ isUser?: boolean }>`
|
|
||||||
align-items: center;
|
|
||||||
background: ${({ theme, isUser }) =>
|
|
||||||
isUser
|
|
||||||
? theme.background.transparent.light
|
|
||||||
: theme.background.transparent.blue};
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
height: 24px;
|
|
||||||
min-width: 24px;
|
|
||||||
border-radius: ${({ theme }) => theme.border.radius.sm};
|
|
||||||
padding: 1px;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const StyledDotsIconContainer = styled.div`
|
|
||||||
align-items: center;
|
|
||||||
border: ${({ theme }) => `1px solid ${theme.border.color.light}`};
|
|
||||||
border-radius: ${({ theme }) => theme.border.radius.md};
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
padding-inline: ${({ theme }) => theme.spacing(1)};
|
|
||||||
`;
|
|
||||||
|
|
||||||
const StyledDotsIcon = styled(IconDotsVertical)`
|
|
||||||
color: ${({ theme }) => theme.font.color.light};
|
|
||||||
transform: rotate(90deg);
|
|
||||||
`;
|
|
||||||
|
|
||||||
const StyledMessageContainer = styled.div`
|
|
||||||
width: 100%;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const dots = keyframes`
|
|
||||||
0% { content: ''; }
|
|
||||||
33% { content: '.'; }
|
|
||||||
66% { content: '..'; }
|
|
||||||
100% { content: '...'; }
|
|
||||||
`;
|
|
||||||
|
|
||||||
const StyledToolCallContainer = styled.div`
|
|
||||||
&::after {
|
|
||||||
display: inline-block;
|
|
||||||
content: '';
|
|
||||||
animation: ${dots} 750ms steps(3, end) infinite;
|
|
||||||
width: 2ch;
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
const StyledButtonsContainer = styled.div`
|
const StyledButtonsContainer = styled.div`
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
gap: ${({ theme }) => theme.spacing(2)};
|
gap: ${({ theme }) => theme.spacing(2)};
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const StyledFilesContainer = styled.div`
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
gap: ${({ theme }) => theme.spacing(2)};
|
|
||||||
flex-wrap: wrap;
|
|
||||||
margin-top: ${({ theme }) => theme.spacing(2)};
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const AIChatTab = ({
|
export const AIChatTab = ({
|
||||||
agentId,
|
agentId,
|
||||||
isWorkflowAgentNodeChat,
|
isWorkflowAgentNodeChat,
|
||||||
@ -205,7 +61,7 @@ export const AIChatTab = ({
|
|||||||
agentId: string;
|
agentId: string;
|
||||||
isWorkflowAgentNodeChat?: boolean;
|
isWorkflowAgentNodeChat?: boolean;
|
||||||
}) => {
|
}) => {
|
||||||
const theme = useTheme();
|
const [isDraggingFile, setIsDraggingFile] = useState(false);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
messages,
|
messages,
|
||||||
@ -216,151 +72,83 @@ export const AIChatTab = ({
|
|||||||
agentStreamingMessage,
|
agentStreamingMessage,
|
||||||
scrollWrapperId,
|
scrollWrapperId,
|
||||||
} = useAgentChat(agentId);
|
} = useAgentChat(agentId);
|
||||||
|
const { uploadFiles } = useAIChatFileUpload({ agentId });
|
||||||
|
|
||||||
const { createAgentChatThread } = useCreateNewAIChatThread({ agentId });
|
const { createAgentChatThread } = useCreateNewAIChatThread({ agentId });
|
||||||
const { navigateCommandMenu } = useCommandMenu();
|
const { navigateCommandMenu } = useCommandMenu();
|
||||||
|
|
||||||
const getAssistantMessageContent = (message: AgentChatMessage) => {
|
|
||||||
if (message.content !== '') {
|
|
||||||
return message.content;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (agentStreamingMessage.streamingText !== '') {
|
|
||||||
return agentStreamingMessage.streamingText;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (agentStreamingMessage.toolCall !== '') {
|
|
||||||
return (
|
|
||||||
<StyledToolCallContainer>
|
|
||||||
{agentStreamingMessage.toolCall}
|
|
||||||
</StyledToolCallContainer>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<StyledDotsIconContainer>
|
|
||||||
<StyledDotsIcon size={theme.icon.size.xl} />
|
|
||||||
</StyledDotsIconContainer>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<StyledContainer>
|
<StyledContainer
|
||||||
{messages.length !== 0 && (
|
isDraggingFile={isDraggingFile}
|
||||||
<StyledScrollWrapper componentInstanceId={scrollWrapperId}>
|
onDragEnter={() => setIsDraggingFile(true)}
|
||||||
{messages.map((msg) => (
|
>
|
||||||
<StyledMessageBubble
|
{isDraggingFile && (
|
||||||
key={msg.id}
|
<DropZone
|
||||||
isUser={msg.role === AgentChatMessageRole.USER}
|
setIsDraggingFile={setIsDraggingFile}
|
||||||
>
|
onUploadFile={(files) => uploadFiles([files])}
|
||||||
<StyledMessageRow
|
|
||||||
isShowingToolCall={
|
|
||||||
msg.role === AgentChatMessageRole.ASSISTANT &&
|
|
||||||
msg.content === '' &&
|
|
||||||
agentStreamingMessage.streamingText === '' &&
|
|
||||||
agentStreamingMessage.toolCall !== ''
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{msg.role === AgentChatMessageRole.ASSISTANT && (
|
|
||||||
<StyledAvatarContainer>
|
|
||||||
<Avatar
|
|
||||||
size="sm"
|
|
||||||
placeholder="AI"
|
|
||||||
Icon={IconSparkles}
|
|
||||||
iconColor={theme.color.blue}
|
|
||||||
/>
|
|
||||||
</StyledAvatarContainer>
|
|
||||||
)}
|
|
||||||
{msg.role === AgentChatMessageRole.USER && (
|
|
||||||
<StyledAvatarContainer isUser>
|
|
||||||
<Avatar size="sm" placeholder="U" type="rounded" />
|
|
||||||
</StyledAvatarContainer>
|
|
||||||
)}
|
|
||||||
<StyledMessageContainer>
|
|
||||||
<StyledMessageText
|
|
||||||
isUser={msg.role === AgentChatMessageRole.USER}
|
|
||||||
>
|
|
||||||
{msg.role === AgentChatMessageRole.ASSISTANT
|
|
||||||
? getAssistantMessageContent(msg)
|
|
||||||
: msg.content}
|
|
||||||
</StyledMessageText>
|
|
||||||
{msg.files.length > 0 && (
|
|
||||||
<StyledFilesContainer>
|
|
||||||
{msg.files.map((file) => (
|
|
||||||
<AgentChatFilePreview key={file.id} file={file} />
|
|
||||||
))}
|
|
||||||
</StyledFilesContainer>
|
|
||||||
)}
|
|
||||||
{msg.content && (
|
|
||||||
<StyledMessageFooter className="message-footer">
|
|
||||||
<span>
|
|
||||||
{beautifyPastDateRelativeToNow(msg.createdAt)}
|
|
||||||
</span>
|
|
||||||
<LightCopyIconButton copyText={msg.content} />
|
|
||||||
</StyledMessageFooter>
|
|
||||||
)}
|
|
||||||
</StyledMessageContainer>
|
|
||||||
</StyledMessageRow>
|
|
||||||
</StyledMessageBubble>
|
|
||||||
))}
|
|
||||||
</StyledScrollWrapper>
|
|
||||||
)}
|
|
||||||
{messages.length === 0 && !isLoading && (
|
|
||||||
<StyledEmptyState>
|
|
||||||
<StyledSparkleIcon>
|
|
||||||
<IconSparkles size={theme.icon.size.lg} color={theme.color.blue} />
|
|
||||||
</StyledSparkleIcon>
|
|
||||||
<StyledTitle>{t`Chat`}</StyledTitle>
|
|
||||||
<StyledDescription>
|
|
||||||
{t`Start a conversation with your AI agent to get workflow insights, task assistance, and process guidance`}
|
|
||||||
</StyledDescription>
|
|
||||||
</StyledEmptyState>
|
|
||||||
)}
|
|
||||||
{isLoading && messages.length === 0 && <AIChatSkeletonLoader />}
|
|
||||||
|
|
||||||
<StyledInputArea>
|
|
||||||
<AgentChatSelectedFilesPreview agentId={agentId} />
|
|
||||||
<TextArea
|
|
||||||
textAreaId={`${agentId}-chat-input`}
|
|
||||||
placeholder={t`Enter a question...`}
|
|
||||||
value={input}
|
|
||||||
onChange={handleInputChange}
|
|
||||||
/>
|
/>
|
||||||
<StyledButtonsContainer>
|
)}
|
||||||
{!isWorkflowAgentNodeChat && (
|
{!isDraggingFile && (
|
||||||
<>
|
<>
|
||||||
<Button
|
{messages.length !== 0 && (
|
||||||
variant="secondary"
|
<StyledScrollWrapper componentInstanceId={scrollWrapperId}>
|
||||||
size="small"
|
{messages.map((message) => (
|
||||||
Icon={IconHistory}
|
<AIChatMessage
|
||||||
onClick={() =>
|
agentStreamingMessage={agentStreamingMessage}
|
||||||
navigateCommandMenu({
|
message={message}
|
||||||
page: CommandMenuPages.ViewPreviousAIChats,
|
key={message.id}
|
||||||
pageTitle: t`View Previous AI Chats`,
|
/>
|
||||||
pageIcon: IconHistory,
|
))}
|
||||||
})
|
</StyledScrollWrapper>
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
size="small"
|
|
||||||
Icon={IconMessageCirclePlus}
|
|
||||||
onClick={() => createAgentChatThread()}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
<AgentChatFileUpload agentId={agentId} />
|
{messages.length === 0 && !isLoading && <AIChatEmptyState />}
|
||||||
<Button
|
{isLoading && messages.length === 0 && <AIChatSkeletonLoader />}
|
||||||
variant="primary"
|
|
||||||
accent="blue"
|
<StyledInputArea>
|
||||||
size="small"
|
<AgentChatSelectedFilesPreview agentId={agentId} />
|
||||||
hotkeys={input && !isLoading ? ['⏎'] : undefined}
|
<TextArea
|
||||||
disabled={!input || isLoading}
|
textAreaId={`${agentId}-chat-input`}
|
||||||
title={t`Send`}
|
placeholder={t`Enter a question...`}
|
||||||
onClick={handleSendMessage}
|
value={input}
|
||||||
/>
|
onChange={handleInputChange}
|
||||||
</StyledButtonsContainer>
|
/>
|
||||||
</StyledInputArea>
|
<StyledButtonsContainer>
|
||||||
|
{!isWorkflowAgentNodeChat && (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="small"
|
||||||
|
Icon={IconHistory}
|
||||||
|
onClick={() =>
|
||||||
|
navigateCommandMenu({
|
||||||
|
page: CommandMenuPages.ViewPreviousAIChats,
|
||||||
|
pageTitle: t`View Previous AI Chats`,
|
||||||
|
pageIcon: IconHistory,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="small"
|
||||||
|
Icon={IconMessageCirclePlus}
|
||||||
|
onClick={() => createAgentChatThread()}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<AgentChatFileUpload agentId={agentId} />
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
accent="blue"
|
||||||
|
size="small"
|
||||||
|
hotkeys={input && !isLoading ? ['⏎'] : undefined}
|
||||||
|
disabled={!input || isLoading}
|
||||||
|
title={t`Send`}
|
||||||
|
onClick={handleSendMessage}
|
||||||
|
/>
|
||||||
|
</StyledButtonsContainer>
|
||||||
|
</StyledInputArea>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</StyledContainer>
|
</StyledContainer>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,18 +1,10 @@
|
|||||||
import { useApolloCoreClient } from '@/object-metadata/hooks/useApolloCoreClient';
|
import { useAIChatFileUpload } from '@/ai/hooks/useAIChatFileUpload';
|
||||||
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
|
|
||||||
import { useRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentStateV2';
|
import { useRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentStateV2';
|
||||||
import { agentChatSelectedFilesComponentState } from '@/workflow/workflow-steps/workflow-actions/ai-agent-action/states/agentChatSelectedFilesComponentState';
|
import { agentChatSelectedFilesComponentState } from '@/workflow/workflow-steps/workflow-actions/ai-agent-action/states/agentChatSelectedFilesComponentState';
|
||||||
import { agentChatUploadedFilesComponentState } from '@/workflow/workflow-steps/workflow-actions/ai-agent-action/states/agentChatUploadedFilesComponentState';
|
|
||||||
import styled from '@emotion/styled';
|
import styled from '@emotion/styled';
|
||||||
import { useLingui } from '@lingui/react/macro';
|
|
||||||
import React, { useRef } from 'react';
|
import React, { useRef } from 'react';
|
||||||
import { isDefined } from 'twenty-shared/utils';
|
|
||||||
import { IconPaperclip } from 'twenty-ui/display';
|
import { IconPaperclip } from 'twenty-ui/display';
|
||||||
import { Button } from 'twenty-ui/input';
|
import { Button } from 'twenty-ui/input';
|
||||||
import {
|
|
||||||
File as FileDocument,
|
|
||||||
useCreateFileMutation,
|
|
||||||
} from '~/generated-metadata/graphql';
|
|
||||||
|
|
||||||
const StyledFileUploadContainer = styled.div`
|
const StyledFileUploadContainer = styled.div`
|
||||||
display: flex;
|
display: flex;
|
||||||
@ -25,73 +17,12 @@ const StyledFileInput = styled.input`
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
export const AgentChatFileUpload = ({ agentId }: { agentId: string }) => {
|
export const AgentChatFileUpload = ({ agentId }: { agentId: string }) => {
|
||||||
const coreClient = useApolloCoreClient();
|
const [, setAgentChatSelectedFiles] = useRecoilComponentStateV2(
|
||||||
const [createFile] = useCreateFileMutation({ client: coreClient });
|
agentChatSelectedFilesComponentState,
|
||||||
const { t } = useLingui();
|
agentId,
|
||||||
const { enqueueErrorSnackBar } = useSnackBar();
|
);
|
||||||
const [agentChatSelectedFiles, setAgentChatSelectedFiles] =
|
|
||||||
useRecoilComponentStateV2(agentChatSelectedFilesComponentState, agentId);
|
|
||||||
const [agentChatUploadedFiles, setAgentChatUploadedFiles] =
|
|
||||||
useRecoilComponentStateV2(agentChatUploadedFilesComponentState, agentId);
|
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const { uploadFiles } = useAIChatFileUpload({ agentId });
|
||||||
const sendFile = async (file: File) => {
|
|
||||||
try {
|
|
||||||
const result = await createFile({
|
|
||||||
variables: {
|
|
||||||
file,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const uploadedFile = result?.data?.createFile;
|
|
||||||
|
|
||||||
if (!isDefined(uploadedFile)) {
|
|
||||||
throw new Error("Couldn't upload the file.");
|
|
||||||
}
|
|
||||||
setAgentChatSelectedFiles(
|
|
||||||
agentChatSelectedFiles.filter((f) => f.name !== file.name),
|
|
||||||
);
|
|
||||||
return uploadedFile;
|
|
||||||
} catch (error) {
|
|
||||||
const fileName = file.name;
|
|
||||||
enqueueErrorSnackBar({
|
|
||||||
message: t`Failed to upload file: ${fileName}`,
|
|
||||||
});
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const uploadFiles = async (files: File[]) => {
|
|
||||||
const uploadResults = await Promise.allSettled(
|
|
||||||
files.map((file) => sendFile(file)),
|
|
||||||
);
|
|
||||||
|
|
||||||
const successfulUploads = uploadResults.reduce<FileDocument[]>(
|
|
||||||
(acc, result) => {
|
|
||||||
if (result.status === 'fulfilled' && isDefined(result.value)) {
|
|
||||||
acc.push(result.value);
|
|
||||||
}
|
|
||||||
return acc;
|
|
||||||
},
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
if (successfulUploads.length > 0) {
|
|
||||||
setAgentChatUploadedFiles([
|
|
||||||
...agentChatUploadedFiles,
|
|
||||||
...successfulUploads,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
const failedCount = uploadResults.filter(
|
|
||||||
(result) => result.status === 'rejected',
|
|
||||||
).length;
|
|
||||||
if (failedCount > 0) {
|
|
||||||
enqueueErrorSnackBar({
|
|
||||||
message: t`${failedCount} file(s) failed to upload`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleFileInputChange = (
|
const handleFileInputChange = (
|
||||||
event: React.ChangeEvent<HTMLInputElement>,
|
event: React.ChangeEvent<HTMLInputElement>,
|
||||||
|
|||||||
Reference in New Issue
Block a user