Fix AI chat streaming persistence when command menu closes (#14854)
Closes [#1583](https://github.com/twentyhq/core-team-issues/issues/1583) - Removed `messageId` column from `File` table and its references in code (unrelated to this PR) - Updated AI chat to use React Context API with persistent provider in `CommandMenuContainer` - Converted all AI chat component states to regular Recoil atoms (no instance context needed) --------- Co-authored-by: Félix Malfait <felix@twenty.com>
This commit is contained in:
co-authored by
Félix Malfait
parent
329af2b670
commit
0648dc5c65
@@ -70,7 +70,6 @@ export type Agent = {
|
||||
export type AgentChatMessage = {
|
||||
__typename?: 'AgentChatMessage';
|
||||
createdAt: Scalars['DateTime'];
|
||||
files: Array<File>;
|
||||
id: Scalars['UUID'];
|
||||
parts: Array<AgentChatMessagePart>;
|
||||
role: Scalars['String'];
|
||||
@@ -1317,7 +1316,6 @@ export type File = {
|
||||
createdAt: Scalars['DateTime'];
|
||||
fullPath: Scalars['String'];
|
||||
id: Scalars['UUID'];
|
||||
messageId?: Maybe<Scalars['UUID']>;
|
||||
name: Scalars['String'];
|
||||
size: Scalars['Float'];
|
||||
type: Scalars['String'];
|
||||
@@ -4674,7 +4672,7 @@ export type GetAgentChatMessagesQueryVariables = Exact<{
|
||||
}>;
|
||||
|
||||
|
||||
export type GetAgentChatMessagesQuery = { __typename?: 'Query', agentChatMessages: Array<{ __typename?: 'AgentChatMessage', id: string, threadId: string, role: string, createdAt: string, parts: Array<{ __typename?: 'AgentChatMessagePart', id: string, messageId: string, orderIndex: number, type: string, textContent?: string | null, reasoningContent?: string | null, toolName?: string | null, toolCallId?: string | null, toolInput?: any | null, toolOutput?: any | null, state?: string | null, errorMessage?: string | null, errorDetails?: any | null, sourceUrlSourceId?: string | null, sourceUrlUrl?: string | null, sourceUrlTitle?: string | null, sourceDocumentSourceId?: string | null, sourceDocumentMediaType?: string | null, sourceDocumentTitle?: string | null, sourceDocumentFilename?: string | null, fileMediaType?: string | null, fileFilename?: string | null, fileUrl?: string | null, providerMetadata?: any | null, createdAt: string }>, files: Array<{ __typename?: 'File', id: string, name: string, fullPath: string, size: number, type: string, createdAt: string }> }> };
|
||||
export type GetAgentChatMessagesQuery = { __typename?: 'Query', agentChatMessages: Array<{ __typename?: 'AgentChatMessage', id: string, threadId: string, role: string, createdAt: string, parts: Array<{ __typename?: 'AgentChatMessagePart', id: string, messageId: string, orderIndex: number, type: string, textContent?: string | null, reasoningContent?: string | null, toolName?: string | null, toolCallId?: string | null, toolInput?: any | null, toolOutput?: any | null, state?: string | null, errorMessage?: string | null, errorDetails?: any | null, sourceUrlSourceId?: string | null, sourceUrlUrl?: string | null, sourceUrlTitle?: string | null, sourceDocumentSourceId?: string | null, sourceDocumentMediaType?: string | null, sourceDocumentTitle?: string | null, sourceDocumentFilename?: string | null, fileMediaType?: string | null, fileFilename?: string | null, fileUrl?: string | null, providerMetadata?: any | null, createdAt: string }> }> };
|
||||
|
||||
export type GetAgentChatThreadsQueryVariables = Exact<{
|
||||
agentId: Scalars['UUID'];
|
||||
@@ -6963,14 +6961,6 @@ export const GetAgentChatMessagesDocument = gql`
|
||||
providerMetadata
|
||||
createdAt
|
||||
}
|
||||
files {
|
||||
id
|
||||
name
|
||||
fullPath
|
||||
size
|
||||
type
|
||||
createdAt
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -70,7 +70,6 @@ export type Agent = {
|
||||
export type AgentChatMessage = {
|
||||
__typename?: 'AgentChatMessage';
|
||||
createdAt: Scalars['DateTime'];
|
||||
files: Array<File>;
|
||||
id: Scalars['UUID'];
|
||||
parts: Array<AgentChatMessagePart>;
|
||||
role: Scalars['String'];
|
||||
@@ -1281,7 +1280,6 @@ export type File = {
|
||||
createdAt: Scalars['DateTime'];
|
||||
fullPath: Scalars['String'];
|
||||
id: Scalars['UUID'];
|
||||
messageId?: Maybe<Scalars['UUID']>;
|
||||
name: Scalars['String'];
|
||||
size: Scalars['Float'];
|
||||
type: Scalars['String'];
|
||||
|
||||
@@ -14,12 +14,15 @@ import { AIChatMessage } from '@/ai/components/AIChatMessage';
|
||||
import { AIChatSkeletonLoader } from '@/ai/components/internal/AIChatSkeletonLoader';
|
||||
import { AgentChatContextPreview } from '@/ai/components/internal/AgentChatContextPreview';
|
||||
import { SendMessageButton } from '@/ai/components/internal/SendMessageButton';
|
||||
import { SendMessageWithRecordsContextButton } from '@/ai/components/internal/SendMessageWithRecordsContextButton';
|
||||
import { AI_CHAT_INPUT_ID } from '@/ai/constants/AiChatInputId';
|
||||
import { useAIChatFileUpload } from '@/ai/hooks/useAIChatFileUpload';
|
||||
import { type UIMessageWithMetadata } from '@/ai/types/UIMessageWithMetadata';
|
||||
import { useAgentChatContextOrThrow } from '@/ai/hooks/useAgentChatContextOrThrow';
|
||||
import { contextStoreCurrentObjectMetadataItemIdComponentState } from '@/context-store/states/contextStoreCurrentObjectMetadataItemIdComponentState';
|
||||
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { useState } from 'react';
|
||||
import { Button } from 'twenty-ui/input';
|
||||
import { useAgentChat } from '../hooks/useAgentChat';
|
||||
|
||||
const StyledContainer = styled.div<{ isDraggingFile: boolean }>`
|
||||
background: ${({ theme }) => theme.background.primary};
|
||||
@@ -56,13 +59,7 @@ const StyledButtonsContainer = styled.div`
|
||||
gap: ${({ theme }) => theme.spacing(2)};
|
||||
`;
|
||||
|
||||
export const AIChatTab = ({
|
||||
agentId,
|
||||
uiMessages,
|
||||
}: {
|
||||
agentId: string;
|
||||
uiMessages: UIMessageWithMetadata[];
|
||||
}) => {
|
||||
export const AIChatTab = ({ agentId }: { agentId: string }) => {
|
||||
const [isDraggingFile, setIsDraggingFile] = useState(false);
|
||||
|
||||
const {
|
||||
@@ -71,11 +68,14 @@ export const AIChatTab = ({
|
||||
handleInputChange,
|
||||
scrollWrapperId,
|
||||
messages,
|
||||
handleSendMessage,
|
||||
isStreaming,
|
||||
} = useAgentChat(agentId, uiMessages);
|
||||
const { uploadFiles } = useAIChatFileUpload({ agentId });
|
||||
} = useAgentChatContextOrThrow();
|
||||
|
||||
const contextStoreCurrentObjectMetadataItemId = useRecoilComponentValue(
|
||||
contextStoreCurrentObjectMetadataItemIdComponentState,
|
||||
);
|
||||
|
||||
const { uploadFiles } = useAIChatFileUpload();
|
||||
const { createAgentChatThread } = useCreateNewAIChatThread({ agentId });
|
||||
const { navigateCommandMenu } = useCommandMenu();
|
||||
|
||||
@@ -110,9 +110,9 @@ export const AIChatTab = ({
|
||||
{isLoading && messages.length === 0 && <AIChatSkeletonLoader />}
|
||||
|
||||
<StyledInputArea>
|
||||
<AgentChatContextPreview agentId={agentId} />
|
||||
<AgentChatContextPreview />
|
||||
<TextArea
|
||||
textAreaId={`${agentId}-chat-input`}
|
||||
textAreaId={AI_CHAT_INPUT_ID}
|
||||
placeholder={t`Enter a question...`}
|
||||
value={input}
|
||||
onChange={handleInputChange}
|
||||
@@ -138,13 +138,12 @@ export const AIChatTab = ({
|
||||
Icon={IconMessageCirclePlus}
|
||||
onClick={() => createAgentChatThread()}
|
||||
/>
|
||||
<AgentChatFileUploadButton agentId={agentId} />
|
||||
<SendMessageButton
|
||||
agentId={agentId}
|
||||
handleSendMessage={handleSendMessage}
|
||||
input={input}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
<AgentChatFileUploadButton />
|
||||
{contextStoreCurrentObjectMetadataItemId ? (
|
||||
<SendMessageWithRecordsContextButton />
|
||||
) : (
|
||||
<SendMessageButton />
|
||||
)}
|
||||
</StyledButtonsContainer>
|
||||
</StyledInputArea>
|
||||
</>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { currentAIChatThreadComponentState } from '@/ai/states/currentAIChatThreadComponentState';
|
||||
import { currentAIChatThreadState } from '@/ai/states/currentAIChatThreadState';
|
||||
import { useOpenAskAIPageInCommandMenu } from '@/command-menu/hooks/useOpenAskAIPageInCommandMenu';
|
||||
import { useRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentState';
|
||||
import { useTheme } from '@emotion/react';
|
||||
import styled from '@emotion/styled';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { useRecoilState } from 'recoil';
|
||||
import { IconSparkles } from 'twenty-ui/display';
|
||||
import { type AgentChatThread } from '~/generated-metadata/graphql';
|
||||
|
||||
@@ -69,18 +69,13 @@ const StyledThreadTitle = styled.div`
|
||||
export const AIChatThreadGroup = ({
|
||||
threads,
|
||||
title,
|
||||
agentId,
|
||||
}: {
|
||||
threads: AgentChatThread[];
|
||||
agentId: string;
|
||||
title: string;
|
||||
}) => {
|
||||
const { t } = useLingui();
|
||||
const theme = useTheme();
|
||||
const [, setCurrentThreadId] = useRecoilComponentState(
|
||||
currentAIChatThreadComponentState,
|
||||
agentId,
|
||||
);
|
||||
const [, setCurrentAIChatThread] = useRecoilState(currentAIChatThreadState);
|
||||
const { openAskAIPage } = useOpenAskAIPageInCommandMenu();
|
||||
|
||||
if (threads.length === 0) {
|
||||
@@ -94,7 +89,7 @@ export const AIChatThreadGroup = ({
|
||||
{threads.map((thread) => (
|
||||
<StyledThreadItem
|
||||
onClick={() => {
|
||||
setCurrentThreadId(thread.id);
|
||||
setCurrentAIChatThread(thread.id);
|
||||
openAskAIPage(thread.title);
|
||||
}}
|
||||
key={thread.id}
|
||||
|
||||
@@ -2,10 +2,10 @@ import styled from '@emotion/styled';
|
||||
|
||||
import { AIChatThreadGroup } from '@/ai/components/AIChatThreadGroup';
|
||||
import { AIChatThreadsListEffect } from '@/ai/components/AIChatThreadsListEffect';
|
||||
import { AIChatSkeletonLoader } from '@/ai/components/internal/AIChatSkeletonLoader';
|
||||
import { useCreateNewAIChatThread } from '@/ai/hooks/useCreateNewAIChatThread';
|
||||
import { groupThreadsByDate } from '@/ai/utils/groupThreadsByDate';
|
||||
import { useHotkeysOnFocusedElement } from '@/ui/utilities/hotkey/hooks/useHotkeysOnFocusedElement';
|
||||
import { AIChatSkeletonLoader } from '@/ai/components/internal/AIChatSkeletonLoader';
|
||||
import { Key } from 'ts-key-enum';
|
||||
import { capitalize } from 'twenty-shared/utils';
|
||||
import { Button } from 'twenty-ui/input';
|
||||
@@ -65,7 +65,6 @@ export const AIChatThreadsList = ({ agentId }: { agentId: string }) => {
|
||||
<AIChatThreadGroup
|
||||
key={title}
|
||||
title={capitalize(title)}
|
||||
agentId={agentId}
|
||||
threads={threads}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { AgentChatContext } from '@/ai/contexts/AgentChatContext';
|
||||
import { useAgentChat } from '@/ai/hooks/useAgentChat';
|
||||
import { useAgentChatData } from '@/ai/hooks/useAgentChatData';
|
||||
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
|
||||
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
|
||||
import { Suspense } from 'react';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { FeatureFlagKey } from '~/generated/graphql';
|
||||
|
||||
const AgentChatProviderContent = ({
|
||||
agentId,
|
||||
children,
|
||||
}: {
|
||||
agentId: string;
|
||||
children: React.ReactNode;
|
||||
}) => {
|
||||
const { uiMessages, isLoading } = useAgentChatData(agentId);
|
||||
const chatState = useAgentChat(agentId, uiMessages);
|
||||
const combinedIsLoading = chatState.isLoading || isLoading;
|
||||
|
||||
return (
|
||||
<AgentChatContext.Provider
|
||||
value={{
|
||||
...chatState,
|
||||
isLoading: combinedIsLoading,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</AgentChatContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const AgentChatProvider = ({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) => {
|
||||
const currentWorkspace = useRecoilValue(currentWorkspaceState);
|
||||
const agentId = currentWorkspace?.defaultAgent?.id;
|
||||
const isAiEnabled = useIsFeatureEnabled(FeatureFlagKey.IS_AI_ENABLED);
|
||||
|
||||
if (!isAiEnabled || !agentId) {
|
||||
return (
|
||||
<AgentChatContext.Provider value={null}>
|
||||
{children}
|
||||
</AgentChatContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<AgentChatProviderContent agentId={agentId}>
|
||||
{children}
|
||||
</AgentChatProviderContent>
|
||||
</Suspense>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,54 @@
|
||||
import { AgentChatContext } from '@/ai/contexts/AgentChatContext';
|
||||
import { useAgentChat } from '@/ai/hooks/useAgentChat';
|
||||
import { useAgentChatData } from '@/ai/hooks/useAgentChatData';
|
||||
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
|
||||
import { Suspense } from 'react';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
|
||||
const AgentChatProviderContent = ({
|
||||
agentId,
|
||||
children,
|
||||
}: {
|
||||
agentId: string;
|
||||
children: React.ReactNode;
|
||||
}) => {
|
||||
const { uiMessages, isLoading } = useAgentChatData(agentId);
|
||||
const chatState = useAgentChat(agentId, uiMessages);
|
||||
const combinedIsLoading = chatState.isLoading || isLoading;
|
||||
|
||||
return (
|
||||
<AgentChatContext.Provider
|
||||
value={{
|
||||
...chatState,
|
||||
isLoading: combinedIsLoading,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</AgentChatContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const AgentChatProvider = ({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) => {
|
||||
const currentWorkspace = useRecoilValue(currentWorkspaceState);
|
||||
const agentId = currentWorkspace?.defaultAgent?.id;
|
||||
|
||||
if (!agentId) {
|
||||
return (
|
||||
<AgentChatContext.Provider value={null}>
|
||||
{children}
|
||||
</AgentChatContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<AgentChatProviderContent agentId={agentId}>
|
||||
{children}
|
||||
</AgentChatProviderContent>
|
||||
</Suspense>
|
||||
);
|
||||
};
|
||||
@@ -1,23 +0,0 @@
|
||||
import { AIChatSkeletonLoader } from '@/ai/components/internal/AIChatSkeletonLoader';
|
||||
import { type UIMessageWithMetadata } from '@/ai/types/UIMessageWithMetadata';
|
||||
import { lazy, Suspense } from 'react';
|
||||
|
||||
const AIChatTab = lazy(() =>
|
||||
import('./AIChatTab').then((module) => ({
|
||||
default: module.AIChatTab,
|
||||
})),
|
||||
);
|
||||
|
||||
export const LazyAIChatTab = ({
|
||||
agentId,
|
||||
uiMessages,
|
||||
}: {
|
||||
agentId: string;
|
||||
uiMessages: UIMessageWithMetadata[];
|
||||
}) => {
|
||||
return (
|
||||
<Suspense fallback={<AIChatSkeletonLoader />}>
|
||||
<AIChatTab agentId={agentId} uiMessages={uiMessages} />
|
||||
</Suspense>
|
||||
);
|
||||
};
|
||||
+10
-9
@@ -1,10 +1,10 @@
|
||||
import { AgentChatContextRecordPreview } from '@/ai/components/internal/AgentChatContextRecordPreview';
|
||||
import { agentChatSelectedFilesComponentState } from '@/ai/states/agentChatSelectedFilesComponentState';
|
||||
import { agentChatUploadedFilesComponentState } from '@/ai/states/agentChatUploadedFilesComponentState';
|
||||
import { agentChatSelectedFilesState } from '@/ai/states/agentChatSelectedFilesState';
|
||||
import { agentChatUploadedFilesState } from '@/ai/states/agentChatUploadedFilesState';
|
||||
import { contextStoreCurrentObjectMetadataItemIdComponentState } from '@/context-store/states/contextStoreCurrentObjectMetadataItemIdComponentState';
|
||||
import { useRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentState';
|
||||
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
|
||||
import styled from '@emotion/styled';
|
||||
import { useRecoilState } from 'recoil';
|
||||
import { AgentChatFilePreview } from './AgentChatFilePreview';
|
||||
|
||||
const StyledContainer = styled.div`
|
||||
@@ -21,11 +21,13 @@ const StyledPreviewsContainer = styled.div`
|
||||
gap: ${({ theme }) => theme.spacing(1)};
|
||||
`;
|
||||
|
||||
export const AgentChatContextPreview = ({ agentId }: { agentId: string }) => {
|
||||
const [agentChatSelectedFiles, setAgentChatSelectedFiles] =
|
||||
useRecoilComponentState(agentChatSelectedFilesComponentState, agentId);
|
||||
const [agentChatUploadedFiles, setAgentChatUploadedFiles] =
|
||||
useRecoilComponentState(agentChatUploadedFilesComponentState, agentId);
|
||||
export const AgentChatContextPreview = () => {
|
||||
const [agentChatSelectedFiles, setAgentChatSelectedFiles] = useRecoilState(
|
||||
agentChatSelectedFilesState,
|
||||
);
|
||||
const [agentChatUploadedFiles, setAgentChatUploadedFiles] = useRecoilState(
|
||||
agentChatUploadedFilesState,
|
||||
);
|
||||
|
||||
const handleRemoveUploadedFile = async (fileIndex: number) => {
|
||||
setAgentChatUploadedFiles(
|
||||
@@ -62,7 +64,6 @@ export const AgentChatContextPreview = ({ agentId }: { agentId: string }) => {
|
||||
))}
|
||||
{contextStoreCurrentObjectMetadataItemId && (
|
||||
<AgentChatContextRecordPreview
|
||||
agentId={agentId}
|
||||
contextStoreCurrentObjectMetadataItemId={
|
||||
contextStoreCurrentObjectMetadataItemId
|
||||
}
|
||||
|
||||
+2
-4
@@ -3,10 +3,10 @@ import { CommandMenuContextRecordChipAvatars } from '@/command-menu/components/C
|
||||
import { getSelectedRecordsContextText } from '@/command-menu/utils/getRecordContextText';
|
||||
import { useFindManyRecordsSelectedInContextStore } from '@/context-store/hooks/useFindManyRecordsSelectedInContextStore';
|
||||
import { useObjectMetadataItemById } from '@/object-metadata/hooks/useObjectMetadataItemById';
|
||||
import { useRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentState';
|
||||
import { useTheme } from '@emotion/react';
|
||||
import styled from '@emotion/styled';
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { useRecoilState } from 'recoil';
|
||||
import { MultipleAvatarChip } from 'twenty-ui/components';
|
||||
import { IconReload, IconX } from 'twenty-ui/display';
|
||||
|
||||
@@ -24,10 +24,8 @@ const StyledChipWrapper = styled.div<{ isActive: boolean }>`
|
||||
`;
|
||||
|
||||
export const AgentChatContextRecordPreview = ({
|
||||
agentId,
|
||||
contextStoreCurrentObjectMetadataItemId,
|
||||
}: {
|
||||
agentId: string;
|
||||
contextStoreCurrentObjectMetadataItemId: string;
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
@@ -41,7 +39,7 @@ export const AgentChatContextRecordPreview = ({
|
||||
});
|
||||
|
||||
const [isAgentChatCurrentContextActive, setIsAgentChatCurrentContextActive] =
|
||||
useRecoilComponentState(isAgentChatCurrentContextActiveState, agentId);
|
||||
useRecoilState(isAgentChatCurrentContextActiveState);
|
||||
|
||||
const Avatars = records.map((record) => (
|
||||
// @todo move this components to be less specific. (Outside of CommandMenu
|
||||
|
||||
+6
-7
@@ -1,8 +1,8 @@
|
||||
import { useAIChatFileUpload } from '@/ai/hooks/useAIChatFileUpload';
|
||||
import { agentChatSelectedFilesComponentState } from '@/ai/states/agentChatSelectedFilesComponentState';
|
||||
import { useSetRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentState';
|
||||
import { agentChatSelectedFilesState } from '@/ai/states/agentChatSelectedFilesState';
|
||||
import styled from '@emotion/styled';
|
||||
import React, { useRef } from 'react';
|
||||
import { useSetRecoilState } from 'recoil';
|
||||
import { IconPaperclip } from 'twenty-ui/display';
|
||||
import { Button } from 'twenty-ui/input';
|
||||
|
||||
@@ -16,13 +16,12 @@ const StyledFileInput = styled.input`
|
||||
display: none;
|
||||
`;
|
||||
|
||||
export const AgentChatFileUploadButton = ({ agentId }: { agentId: string }) => {
|
||||
const setAgentChatSelectedFiles = useSetRecoilComponentState(
|
||||
agentChatSelectedFilesComponentState,
|
||||
agentId,
|
||||
export const AgentChatFileUploadButton = () => {
|
||||
const setAgentChatSelectedFiles = useSetRecoilState(
|
||||
agentChatSelectedFilesState,
|
||||
);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const { uploadFiles } = useAIChatFileUpload({ agentId });
|
||||
const { uploadFiles } = useAIChatFileUpload();
|
||||
|
||||
const handleFileInputChange = (
|
||||
event: React.ChangeEvent<HTMLInputElement>,
|
||||
|
||||
@@ -1,25 +1,42 @@
|
||||
import { AI_CHAT_INPUT_ID } from '@/ai/constants/AiChatInputId';
|
||||
import { useAgentChatContextOrThrow } from '@/ai/hooks/useAgentChatContextOrThrow';
|
||||
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
|
||||
import { useHotkeysOnFocusedElement } from '@/ui/utilities/hotkey/hooks/useHotkeysOnFocusedElement';
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { Key } from 'ts-key-enum';
|
||||
import { Button } from 'twenty-ui/input';
|
||||
|
||||
export const SendMessageButton = ({
|
||||
handleSendMessage,
|
||||
input,
|
||||
isLoading,
|
||||
records,
|
||||
}: {
|
||||
agentId: string;
|
||||
handleSendMessage: () => void;
|
||||
input: string;
|
||||
isLoading: boolean;
|
||||
records?: ObjectRecord[];
|
||||
}) => {
|
||||
const { handleSendMessage, isLoading, input } = useAgentChatContextOrThrow();
|
||||
|
||||
useHotkeysOnFocusedElement({
|
||||
keys: [Key.Enter],
|
||||
callback: (event: KeyboardEvent) => {
|
||||
if (!event.ctrlKey && !event.metaKey) {
|
||||
event.preventDefault();
|
||||
handleSendMessage(records);
|
||||
}
|
||||
},
|
||||
focusId: AI_CHAT_INPUT_ID,
|
||||
dependencies: [input, isLoading],
|
||||
options: {
|
||||
enableOnFormTags: true,
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Button
|
||||
hotkeys={input && !isLoading ? ['⏎'] : undefined}
|
||||
onClick={() => handleSendMessage(records)}
|
||||
disabled={!input || isLoading}
|
||||
variant="primary"
|
||||
accent="blue"
|
||||
size="small"
|
||||
hotkeys={input && !isLoading ? ['⏎'] : undefined}
|
||||
disabled={!input || isLoading}
|
||||
title={t`Send`}
|
||||
onClick={handleSendMessage}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
import { SendMessageButton } from '@/ai/components/internal/SendMessageButton';
|
||||
import { useFindManyRecordsSelectedInContextStore } from '@/context-store/hooks/useFindManyRecordsSelectedInContextStore';
|
||||
|
||||
export const SendMessageWithRecordsContextButton = () => {
|
||||
const { records } = useFindManyRecordsSelectedInContextStore({
|
||||
limit: 10,
|
||||
});
|
||||
|
||||
return <SendMessageButton records={records} />;
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export const AI_CHAT_INPUT_ID = 'ai-chat-input';
|
||||
@@ -0,0 +1,26 @@
|
||||
import { type AIChatObjectMetadataAndRecordContext } from '@/ai/states/agentChatContextState';
|
||||
import { type UIMessageWithMetadata } from '@/ai/types/UIMessageWithMetadata';
|
||||
import { createContext } from 'react';
|
||||
import { type ObjectRecord } from '../../object-record/types/ObjectRecord';
|
||||
|
||||
export type AgentChatContextValue = {
|
||||
messages: UIMessageWithMetadata[];
|
||||
isStreaming: boolean;
|
||||
isLoading: boolean;
|
||||
error?: Error;
|
||||
|
||||
input: string;
|
||||
handleInputChange: (value: string) => void;
|
||||
|
||||
handleSendMessage: (records?: ObjectRecord[]) => Promise<void>;
|
||||
handleSetContext: (
|
||||
items: Array<AIChatObjectMetadataAndRecordContext>,
|
||||
) => Promise<void>;
|
||||
|
||||
scrollWrapperId: string;
|
||||
context: Array<AIChatObjectMetadataAndRecordContext>;
|
||||
};
|
||||
|
||||
export const AgentChatContext = createContext<AgentChatContextValue | null>(
|
||||
null,
|
||||
);
|
||||
@@ -34,14 +34,6 @@ export const GET_AGENT_CHAT_MESSAGES = gql`
|
||||
providerMetadata
|
||||
createdAt
|
||||
}
|
||||
files {
|
||||
id
|
||||
name
|
||||
fullPath
|
||||
size
|
||||
type
|
||||
createdAt
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -1,24 +1,26 @@
|
||||
import { agentChatSelectedFilesComponentState } from '@/ai/states/agentChatSelectedFilesComponentState';
|
||||
import { agentChatUploadedFilesComponentState } from '@/ai/states/agentChatUploadedFilesComponentState';
|
||||
import { agentChatSelectedFilesState } from '@/ai/states/agentChatSelectedFilesState';
|
||||
import { agentChatUploadedFilesState } from '@/ai/states/agentChatUploadedFilesState';
|
||||
import { useApolloCoreClient } from '@/object-metadata/hooks/useApolloCoreClient';
|
||||
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
|
||||
import { useRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentState';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { type FileUIPart } from 'ai';
|
||||
import { useRecoilState } from 'recoil';
|
||||
import { buildSignedPath, isDefined } from 'twenty-shared/utils';
|
||||
import { REACT_APP_SERVER_BASE_URL } from '~/config';
|
||||
import { useUploadFileMutation } from '~/generated-metadata/graphql';
|
||||
import { FileFolder } from '~/generated/graphql';
|
||||
|
||||
export const useAIChatFileUpload = ({ agentId }: { agentId: string }) => {
|
||||
export const useAIChatFileUpload = () => {
|
||||
const coreClient = useApolloCoreClient();
|
||||
const [uploadFile] = useUploadFileMutation({ client: coreClient });
|
||||
const { t } = useLingui();
|
||||
const { enqueueErrorSnackBar } = useSnackBar();
|
||||
const [agentChatSelectedFiles, setAgentChatSelectedFiles] =
|
||||
useRecoilComponentState(agentChatSelectedFilesComponentState, agentId);
|
||||
const [agentChatUploadedFiles, setAgentChatUploadedFiles] =
|
||||
useRecoilComponentState(agentChatUploadedFilesComponentState, agentId);
|
||||
const [agentChatSelectedFiles, setAgentChatSelectedFiles] = useRecoilState(
|
||||
agentChatSelectedFilesState,
|
||||
);
|
||||
const [agentChatUploadedFiles, setAgentChatUploadedFiles] = useRecoilState(
|
||||
agentChatUploadedFilesState,
|
||||
);
|
||||
|
||||
const sendFile = async (file: File): Promise<FileUIPart | null> => {
|
||||
try {
|
||||
|
||||
@@ -1,24 +1,21 @@
|
||||
import { useRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentState';
|
||||
import { useRecoilState } from 'recoil';
|
||||
import { Key } from 'ts-key-enum';
|
||||
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
|
||||
import { useRecoilState, useRecoilValue } from 'recoil';
|
||||
|
||||
import {
|
||||
type AIChatObjectMetadataAndRecordContext,
|
||||
agentChatObjectMetadataAndRecordContextState,
|
||||
} from '@/ai/states/agentChatObjectMetadataAndRecordContextState';
|
||||
import { agentChatSelectedFilesComponentState } from '@/ai/states/agentChatSelectedFilesComponentState';
|
||||
import { agentChatUploadedFilesComponentState } from '@/ai/states/agentChatUploadedFilesComponentState';
|
||||
import { currentAIChatThreadComponentState } from '@/ai/states/currentAIChatThreadComponentState';
|
||||
agentChatContextState,
|
||||
} from '@/ai/states/agentChatContextState';
|
||||
import { agentChatSelectedFilesState } from '@/ai/states/agentChatSelectedFilesState';
|
||||
import { agentChatUploadedFilesState } from '@/ai/states/agentChatUploadedFilesState';
|
||||
import { currentAIChatThreadState } from '@/ai/states/currentAIChatThreadState';
|
||||
import { isAgentChatCurrentContextActiveState } from '@/ai/states/isAgentChatCurrentContextActiveState';
|
||||
import { type UIMessageWithMetadata } from '@/ai/types/UIMessageWithMetadata';
|
||||
import { getTokenPair } from '@/apollo/utils/getTokenPair';
|
||||
import { useFindManyRecordsSelectedInContextStore } from '@/context-store/hooks/useFindManyRecordsSelectedInContextStore';
|
||||
import { contextStoreCurrentObjectMetadataItemIdComponentState } from '@/context-store/states/contextStoreCurrentObjectMetadataItemIdComponentState';
|
||||
import { useGetObjectMetadataItemById } from '@/object-metadata/hooks/useGetObjectMetadataItemById';
|
||||
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
|
||||
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
|
||||
import { useHotkeysOnFocusedElement } from '@/ui/utilities/hotkey/hooks/useHotkeysOnFocusedElement';
|
||||
import { useScrollWrapperHTMLElement } from '@/ui/utilities/scroll/hooks/useScrollWrapperHTMLElement';
|
||||
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
|
||||
import { useChat } from '@ai-sdk/react';
|
||||
import { DefaultChatTransport } from 'ai';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
@@ -35,33 +32,22 @@ export const useAgentChat = (
|
||||
contextStoreCurrentObjectMetadataItemIdComponentState,
|
||||
);
|
||||
|
||||
const { records } = useFindManyRecordsSelectedInContextStore({
|
||||
limit: 10,
|
||||
});
|
||||
|
||||
const isAgentChatCurrentContextActive = useRecoilComponentValue(
|
||||
const isAgentChatCurrentContextActive = useRecoilValue(
|
||||
isAgentChatCurrentContextActiveState,
|
||||
agentId,
|
||||
);
|
||||
|
||||
const agentChatSelectedFiles = useRecoilComponentValue(
|
||||
agentChatSelectedFilesComponentState,
|
||||
agentId,
|
||||
const agentChatSelectedFiles = useRecoilValue(agentChatSelectedFilesState);
|
||||
|
||||
const [agentChatContext, setAgentChatContext] = useRecoilState(
|
||||
agentChatContextState,
|
||||
);
|
||||
|
||||
const [agentChatContext, setAgentChatContext] = useRecoilComponentState(
|
||||
agentChatObjectMetadataAndRecordContextState,
|
||||
agentId,
|
||||
);
|
||||
const currentAIChatThread = useRecoilValue(currentAIChatThreadState);
|
||||
|
||||
const currentThreadId = useRecoilComponentValue(
|
||||
currentAIChatThreadComponentState,
|
||||
agentId,
|
||||
const [agentChatUploadedFiles, setAgentChatUploadedFiles] = useRecoilState(
|
||||
agentChatUploadedFilesState,
|
||||
);
|
||||
|
||||
const [agentChatUploadedFiles, setAgentChatUploadedFiles] =
|
||||
useRecoilComponentState(agentChatUploadedFilesComponentState, agentId);
|
||||
|
||||
const [agentChatInput, setAgentChatInput] =
|
||||
useRecoilState(agentChatInputState);
|
||||
|
||||
@@ -80,7 +66,7 @@ export const useAgentChat = (
|
||||
}),
|
||||
}),
|
||||
messages: uiMessages,
|
||||
id: currentThreadId as string,
|
||||
id: `${currentAIChatThread}-${uiMessages.length}`,
|
||||
onError: (error) => {
|
||||
enqueueErrorSnackBar({ message: error.message });
|
||||
},
|
||||
@@ -96,9 +82,16 @@ export const useAgentChat = (
|
||||
};
|
||||
|
||||
const isLoading =
|
||||
!currentThreadId || isStreaming || agentChatSelectedFiles.length > 0;
|
||||
!currentAIChatThread || isStreaming || agentChatSelectedFiles.length > 0;
|
||||
|
||||
const handleSendMessage = async (records?: ObjectRecord[]) => {
|
||||
if (agentChatInput.trim() === '' || isLoading === true) {
|
||||
return;
|
||||
}
|
||||
|
||||
const content = agentChatInput.trim();
|
||||
setAgentChatInput('');
|
||||
|
||||
const sendChatMessage = async (content: string) => {
|
||||
const recordIdsByObjectMetadataNameSingular = [];
|
||||
|
||||
if (
|
||||
@@ -121,7 +114,7 @@ export const useAgentChat = (
|
||||
},
|
||||
{
|
||||
body: {
|
||||
threadId: currentThreadId,
|
||||
threadId: currentAIChatThread,
|
||||
recordIdsByObjectMetadataNameSingular,
|
||||
},
|
||||
},
|
||||
@@ -131,36 +124,12 @@ export const useAgentChat = (
|
||||
setTimeout(scrollToBottom, 100);
|
||||
};
|
||||
|
||||
const handleSendMessage = async () => {
|
||||
if (agentChatInput.trim() === '' || isLoading === true) {
|
||||
return;
|
||||
}
|
||||
const content = agentChatInput.trim();
|
||||
setAgentChatInput('');
|
||||
await sendChatMessage(content);
|
||||
};
|
||||
|
||||
const handleSetContext = async (
|
||||
items: Array<AIChatObjectMetadataAndRecordContext>,
|
||||
) => {
|
||||
setAgentChatContext(items);
|
||||
};
|
||||
|
||||
useHotkeysOnFocusedElement({
|
||||
keys: [Key.Enter],
|
||||
callback: (event: KeyboardEvent) => {
|
||||
if (!event.ctrlKey && !event.metaKey) {
|
||||
event.preventDefault();
|
||||
handleSendMessage();
|
||||
}
|
||||
},
|
||||
focusId: `${agentId}-chat-input`,
|
||||
dependencies: [agentChatInput, isLoading],
|
||||
options: {
|
||||
enableOnFormTags: true,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
handleInputChange: (value: string) => setAgentChatInput(value),
|
||||
messages,
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { AgentChatContext } from '@/ai/contexts/AgentChatContext';
|
||||
import { useContext } from 'react';
|
||||
|
||||
export const useAgentChatContextOrThrow = () => {
|
||||
const context = useContext(AgentChatContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
'AgentChatContext not found. Please wrap your component tree with <AgentChatContextProvider> before using useAgentChatContextOrThrow().',
|
||||
);
|
||||
}
|
||||
|
||||
return context;
|
||||
};
|
||||
@@ -0,0 +1,37 @@
|
||||
import { currentAIChatThreadState } from '@/ai/states/currentAIChatThreadState';
|
||||
import { mapDBMessagesToUIMessages } from '@/ai/utils/mapDBMessagesToUIMessages';
|
||||
import { useRecoilState } from 'recoil';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import {
|
||||
useGetAgentChatMessagesQuery,
|
||||
useGetAgentChatThreadsQuery,
|
||||
} from '~/generated-metadata/graphql';
|
||||
|
||||
export const useAgentChatData = (agentId: string) => {
|
||||
const [currentAIChatThread, setCurrentAIChatThread] = useRecoilState(
|
||||
currentAIChatThreadState,
|
||||
);
|
||||
|
||||
const { loading: threadsLoading } = useGetAgentChatThreadsQuery({
|
||||
variables: { agentId },
|
||||
skip: isDefined(currentAIChatThread) || !isDefined(agentId),
|
||||
onCompleted: (data) => {
|
||||
if (data.agentChatThreads.length > 0) {
|
||||
setCurrentAIChatThread(data.agentChatThreads[0].id);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const { loading: messagesLoading, data } = useGetAgentChatMessagesQuery({
|
||||
variables: { threadId: currentAIChatThread! },
|
||||
skip: !isDefined(currentAIChatThread),
|
||||
});
|
||||
|
||||
const uiMessages = mapDBMessagesToUIMessages(data?.agentChatMessages || []);
|
||||
const isLoading = messagesLoading || threadsLoading;
|
||||
|
||||
return {
|
||||
uiMessages,
|
||||
isLoading,
|
||||
};
|
||||
};
|
||||
@@ -1,19 +1,16 @@
|
||||
import { currentAIChatThreadComponentState } from '@/ai/states/currentAIChatThreadComponentState';
|
||||
import { currentAIChatThreadState } from '@/ai/states/currentAIChatThreadState';
|
||||
import { useOpenAskAIPageInCommandMenu } from '@/command-menu/hooks/useOpenAskAIPageInCommandMenu';
|
||||
import { useRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentState';
|
||||
import { useRecoilState } from 'recoil';
|
||||
import { useCreateAgentChatThreadMutation } from '~/generated-metadata/graphql';
|
||||
|
||||
export const useCreateNewAIChatThread = ({ agentId }: { agentId: string }) => {
|
||||
const [, setCurrentThreadId] = useRecoilComponentState(
|
||||
currentAIChatThreadComponentState,
|
||||
agentId,
|
||||
);
|
||||
const [, setCurrentAIChatThread] = useRecoilState(currentAIChatThreadState);
|
||||
|
||||
const { openAskAIPage } = useOpenAskAIPageInCommandMenu();
|
||||
const [createAgentChatThread] = useCreateAgentChatThreadMutation({
|
||||
variables: { input: { agentId } },
|
||||
onCompleted: (data) => {
|
||||
setCurrentThreadId(data.createAgentChatThread.id);
|
||||
setCurrentAIChatThread(data.createAgentChatThread.id);
|
||||
openAskAIPage();
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { atom } from 'recoil';
|
||||
|
||||
export type AIChatObjectMetadataAndRecordContext = {
|
||||
type: 'objectMetadataId' | 'recordId';
|
||||
id: string;
|
||||
};
|
||||
|
||||
export const agentChatContextState = atom<
|
||||
Array<AIChatObjectMetadataAndRecordContext>
|
||||
>({
|
||||
key: 'ai/agentChatContextState',
|
||||
default: [],
|
||||
});
|
||||
-14
@@ -1,14 +0,0 @@
|
||||
import { AgentChatMessagesComponentInstanceContext } from '@/ai/states/agentChatMessagesComponentState';
|
||||
import { createComponentState } from '@/ui/utilities/state/component-state/utils/createComponentState';
|
||||
|
||||
export type AIChatObjectMetadataAndRecordContext = {
|
||||
type: 'objectMetadataId' | 'recordId';
|
||||
id: string;
|
||||
};
|
||||
|
||||
export const agentChatObjectMetadataAndRecordContextState =
|
||||
createComponentState<Array<AIChatObjectMetadataAndRecordContext>>({
|
||||
defaultValue: [],
|
||||
key: 'agentChatObjectMetadataAndRecordContextState',
|
||||
componentInstanceContext: AgentChatMessagesComponentInstanceContext,
|
||||
});
|
||||
@@ -1,10 +0,0 @@
|
||||
import { AgentChatMessagesComponentInstanceContext } from '@/ai/states/agentChatMessagesComponentState';
|
||||
import { createComponentState } from '@/ui/utilities/state/component-state/utils/createComponentState';
|
||||
|
||||
export const agentChatSelectedFilesComponentState = createComponentState<
|
||||
File[]
|
||||
>({
|
||||
key: 'agentChatSelectedFilesComponentState',
|
||||
defaultValue: [],
|
||||
componentInstanceContext: AgentChatMessagesComponentInstanceContext,
|
||||
});
|
||||
@@ -0,0 +1,6 @@
|
||||
import { atom } from 'recoil';
|
||||
|
||||
export const agentChatSelectedFilesState = atom<File[]>({
|
||||
key: 'ai/agentChatSelectedFilesState',
|
||||
default: [],
|
||||
});
|
||||
@@ -1,11 +0,0 @@
|
||||
import { AgentChatMessagesComponentInstanceContext } from '@/ai/states/agentChatMessagesComponentState';
|
||||
import { createComponentState } from '@/ui/utilities/state/component-state/utils/createComponentState';
|
||||
import { type FileUIPart } from 'ai';
|
||||
|
||||
export const agentChatUploadedFilesComponentState = createComponentState<
|
||||
FileUIPart[]
|
||||
>({
|
||||
key: 'agentChatUploadedFilesComponentState',
|
||||
defaultValue: [],
|
||||
componentInstanceContext: AgentChatMessagesComponentInstanceContext,
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
import { type FileUIPart } from 'ai';
|
||||
import { atom } from 'recoil';
|
||||
|
||||
export const agentChatUploadedFilesState = atom<FileUIPart[]>({
|
||||
key: 'ai/agentChatUploadedFilesState',
|
||||
default: [],
|
||||
});
|
||||
@@ -1,10 +0,0 @@
|
||||
import { CommandMenuPageComponentInstanceContext } from '@/command-menu/states/contexts/CommandMenuPageComponentInstanceContext';
|
||||
import { createComponentState } from '@/ui/utilities/state/component-state/utils/createComponentState';
|
||||
|
||||
export const currentAIChatThreadComponentState = createComponentState<
|
||||
string | null
|
||||
>({
|
||||
key: 'currentAIChatThreadComponentState',
|
||||
defaultValue: null,
|
||||
componentInstanceContext: CommandMenuPageComponentInstanceContext,
|
||||
});
|
||||
@@ -0,0 +1,6 @@
|
||||
import { atom } from 'recoil';
|
||||
|
||||
export const currentAIChatThreadState = atom<string | null>({
|
||||
key: 'ai/currentAIChatThreadState',
|
||||
default: null,
|
||||
});
|
||||
+5
-11
@@ -1,12 +1,6 @@
|
||||
import { createComponentInstanceContext } from '@/ui/utilities/state/component-state/utils/createComponentInstanceContext';
|
||||
import { createComponentState } from '@/ui/utilities/state/component-state/utils/createComponentState';
|
||||
import { atom } from 'recoil';
|
||||
|
||||
export const IsAgentChatCurrentContextActiveInstanceContext =
|
||||
createComponentInstanceContext();
|
||||
|
||||
export const isAgentChatCurrentContextActiveState =
|
||||
createComponentState<boolean>({
|
||||
defaultValue: true,
|
||||
key: 'isAgentChatCurrentContextActiveState',
|
||||
componentInstanceContext: IsAgentChatCurrentContextActiveInstanceContext,
|
||||
});
|
||||
export const isAgentChatCurrentContextActiveState = atom<boolean>({
|
||||
key: 'ai/isAgentChatCurrentContextActiveState',
|
||||
default: true,
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ActionMenuComponentInstanceContext } from '@/action-menu/states/contexts/ActionMenuComponentInstanceContext';
|
||||
import { AgentChatProvider } from '@/ai/components/AgentChatProvider';
|
||||
import { CommandMenuOpenContainer } from '@/command-menu/components/CommandMenuOpenContainer';
|
||||
import { COMMAND_MENU_COMPONENT_INSTANCE_ID } from '@/command-menu/constants/CommandMenuComponentInstanceId';
|
||||
import { useCommandMenuCloseAnimationCompleteCleanup } from '@/command-menu/hooks/useCommandMenuCloseAnimationCompleteCleanup';
|
||||
@@ -55,14 +56,16 @@ export const CommandMenuContainer = ({
|
||||
<ActionMenuComponentInstanceContext.Provider
|
||||
value={{ instanceId: COMMAND_MENU_COMPONENT_INSTANCE_ID }}
|
||||
>
|
||||
<AnimatePresence
|
||||
mode="wait"
|
||||
onExitComplete={commandMenuCloseAnimationCompleteCleanup}
|
||||
>
|
||||
{isCommandMenuOpened && (
|
||||
<CommandMenuOpenContainer>{children}</CommandMenuOpenContainer>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
<AgentChatProvider>
|
||||
<AnimatePresence
|
||||
mode="wait"
|
||||
onExitComplete={commandMenuCloseAnimationCompleteCleanup}
|
||||
>
|
||||
{isCommandMenuOpened && (
|
||||
<CommandMenuOpenContainer>{children}</CommandMenuOpenContainer>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</AgentChatProvider>
|
||||
</ActionMenuComponentInstanceContext.Provider>
|
||||
</ContextStoreComponentInstanceContext.Provider>
|
||||
</RecordComponentInstanceContextsWrapper>
|
||||
|
||||
+2
-41
@@ -1,17 +1,8 @@
|
||||
import { LazyAIChatTab } from '@/ai/components/LazyAIChatTab';
|
||||
import { AIChatSkeletonLoader } from '@/ai/components/internal/AIChatSkeletonLoader';
|
||||
import { currentAIChatThreadComponentState } from '@/ai/states/currentAIChatThreadComponentState';
|
||||
import { mapDBMessagesToUIMessages } from '@/ai/utils/mapDBMessagesToUIMessages';
|
||||
import { AIChatTab } from '@/ai/components/AIChatTab';
|
||||
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
|
||||
import { useRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentState';
|
||||
import styled from '@emotion/styled';
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import {
|
||||
useGetAgentChatMessagesQuery,
|
||||
useGetAgentChatThreadsQuery,
|
||||
} from '~/generated-metadata/graphql';
|
||||
|
||||
const StyledContainer = styled.div`
|
||||
height: 100%;
|
||||
@@ -31,32 +22,6 @@ export const CommandMenuAskAIPage = () => {
|
||||
const currentWorkspace = useRecoilValue(currentWorkspaceState);
|
||||
const agentId = currentWorkspace?.defaultAgent?.id;
|
||||
|
||||
const [currentThreadId, setCurrentThreadId] = useRecoilComponentState(
|
||||
currentAIChatThreadComponentState,
|
||||
agentId,
|
||||
);
|
||||
|
||||
const { loading: threadsLoading } = useGetAgentChatThreadsQuery({
|
||||
variables: { agentId: agentId! },
|
||||
skip: isDefined(currentThreadId) || !isDefined(agentId),
|
||||
onCompleted: (data) => {
|
||||
if (data.agentChatThreads.length > 0) {
|
||||
setCurrentThreadId(data.agentChatThreads[0].id);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const { loading, data } = useGetAgentChatMessagesQuery({
|
||||
variables: { threadId: currentThreadId ?? '' },
|
||||
skip: !isDefined(currentThreadId),
|
||||
});
|
||||
|
||||
const isLoading = loading || !currentThreadId || threadsLoading;
|
||||
|
||||
if (isLoading) {
|
||||
return <AIChatSkeletonLoader />;
|
||||
}
|
||||
|
||||
if (!agentId) {
|
||||
return (
|
||||
<StyledContainer>
|
||||
@@ -67,11 +32,7 @@ export const CommandMenuAskAIPage = () => {
|
||||
|
||||
return (
|
||||
<StyledContainer>
|
||||
<LazyAIChatTab
|
||||
agentId={agentId}
|
||||
key={currentThreadId}
|
||||
uiMessages={mapDBMessagesToUIMessages(data?.agentChatMessages || [])}
|
||||
/>
|
||||
<AIChatTab agentId={agentId} />
|
||||
</StyledContainer>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -2,7 +2,6 @@ import { Logger } from '@nestjs/common';
|
||||
|
||||
import { Command, CommandRunner } from 'nest-commander';
|
||||
|
||||
import { CleanupOrphanedFilesCronCommand } from 'src/engine/core-modules/file/crons/commands/cleanup-orphaned-files.cron.command';
|
||||
import { CheckPublicDomainsValidRecordsCronCommand } from 'src/engine/core-modules/public-domain/crons/commands/check-public-domains-valid-records.cron.command';
|
||||
import { CheckCustomDomainValidRecordsCronCommand } from 'src/engine/core-modules/workspace/crons/commands/check-custom-domain-valid-records.cron.command';
|
||||
import { CronTriggerCronCommand } from 'src/engine/metadata-modules/cron-trigger/crons/commands/cron-trigger.cron.command';
|
||||
@@ -35,7 +34,6 @@ export class CronRegisterAllCommand extends CommandRunner {
|
||||
private readonly calendarEventsImportCronCommand: CalendarEventsImportCronCommand,
|
||||
private readonly calendarOngoingStaleCronCommand: CalendarOngoingStaleCronCommand,
|
||||
private readonly workflowCronTriggerCronCommand: WorkflowCronTriggerCronCommand,
|
||||
private readonly cleanupOrphanedFilesCronCommand: CleanupOrphanedFilesCronCommand,
|
||||
private readonly checkCustomDomainValidRecordsCronCommand: CheckCustomDomainValidRecordsCronCommand,
|
||||
private readonly checkPublicDomainsValidRecordsCronCommand: CheckPublicDomainsValidRecordsCronCommand,
|
||||
private readonly workflowRunEnqueueCronCommand: WorkflowRunEnqueueCronCommand,
|
||||
@@ -76,10 +74,6 @@ export class CronRegisterAllCommand extends CommandRunner {
|
||||
name: 'CalendarOngoingStale',
|
||||
command: this.calendarOngoingStaleCronCommand,
|
||||
},
|
||||
{
|
||||
name: 'CleanupOrphanedFiles',
|
||||
command: this.cleanupOrphanedFilesCronCommand,
|
||||
},
|
||||
{
|
||||
name: 'CheckCustomDomainValidRecords',
|
||||
command: this.checkCustomDomainValidRecordsCronCommand,
|
||||
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
import { type MigrationInterface, type QueryRunner } from 'typeorm';
|
||||
|
||||
export class RemoveMessageIdFromFileTable1759378531410
|
||||
implements MigrationInterface
|
||||
{
|
||||
name = 'RemoveMessageIdFromFileTable1759378531410';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "core"."file" DROP CONSTRAINT "FK_a78a68c3f577a485dd4c741909f"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "core"."file" DROP COLUMN "messageId"`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`ALTER TABLE "core"."file" ADD "messageId" uuid`);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "core"."file" ADD CONSTRAINT "FK_a78a68c3f577a485dd4c741909f" FOREIGN KEY ("messageId") REFERENCES "core"."agentChatMessage"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
|
||||
);
|
||||
}
|
||||
}
|
||||
-32
@@ -1,32 +0,0 @@
|
||||
import { Command, CommandRunner } from 'nest-commander';
|
||||
|
||||
import {
|
||||
CLEANUP_ORPHANED_FILES_CRON_PATTERN,
|
||||
CleanupOrphanedFilesCronJob,
|
||||
} from 'src/engine/core-modules/file/crons/jobs/cleanup-orphaned-files.cron.job';
|
||||
import { InjectMessageQueue } from 'src/engine/core-modules/message-queue/decorators/message-queue.decorator';
|
||||
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
|
||||
import { MessageQueueService } from 'src/engine/core-modules/message-queue/services/message-queue.service';
|
||||
|
||||
@Command({
|
||||
name: 'cron:file:cleanup-orphaned-files',
|
||||
description: 'Starts a cron job to clean up orphaned files (no messageId)',
|
||||
})
|
||||
export class CleanupOrphanedFilesCronCommand extends CommandRunner {
|
||||
constructor(
|
||||
@InjectMessageQueue(MessageQueue.cronQueue)
|
||||
private readonly messageQueueService: MessageQueueService,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
async run(): Promise<void> {
|
||||
await this.messageQueueService.addCron<undefined>({
|
||||
jobName: CleanupOrphanedFilesCronJob.name,
|
||||
data: undefined,
|
||||
options: {
|
||||
repeat: { pattern: CLEANUP_ORPHANED_FILES_CRON_PATTERN },
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
-67
@@ -1,67 +0,0 @@
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import { WorkspaceActivationStatus } from 'twenty-shared/workspace';
|
||||
import { IsNull, LessThan, Repository } from 'typeorm';
|
||||
|
||||
import { SentryCronMonitor } from 'src/engine/core-modules/cron/sentry-cron-monitor.decorator';
|
||||
import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity';
|
||||
import { FileMetadataService } from 'src/engine/core-modules/file/services/file-metadata.service';
|
||||
import { Process } from 'src/engine/core-modules/message-queue/decorators/process.decorator';
|
||||
import { Processor } from 'src/engine/core-modules/message-queue/decorators/processor.decorator';
|
||||
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
|
||||
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
|
||||
export const CLEANUP_ORPHANED_FILES_CRON_PATTERN = '0 2 * * *';
|
||||
|
||||
@Processor(MessageQueue.cronQueue)
|
||||
export class CleanupOrphanedFilesCronJob {
|
||||
constructor(
|
||||
@InjectRepository(Workspace)
|
||||
private readonly workspaceRepository: Repository<Workspace>,
|
||||
@InjectRepository(FileEntity)
|
||||
private readonly fileRepository: Repository<FileEntity>,
|
||||
private readonly fileMetadataService: FileMetadataService,
|
||||
) {}
|
||||
|
||||
@Process(CleanupOrphanedFilesCronJob.name)
|
||||
@SentryCronMonitor(
|
||||
CleanupOrphanedFilesCronJob.name,
|
||||
CLEANUP_ORPHANED_FILES_CRON_PATTERN,
|
||||
)
|
||||
async handle(): Promise<void> {
|
||||
const activeWorkspaces = await this.workspaceRepository.find({
|
||||
where: {
|
||||
activationStatus: WorkspaceActivationStatus.ACTIVE,
|
||||
},
|
||||
select: ['id'],
|
||||
});
|
||||
|
||||
if (activeWorkspaces.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000);
|
||||
|
||||
const orphanedFiles = await this.fileRepository.find({
|
||||
select: ['id', 'workspaceId', 'fullPath'],
|
||||
where: {
|
||||
messageId: IsNull(),
|
||||
createdAt: LessThan(oneHourAgo),
|
||||
},
|
||||
});
|
||||
|
||||
if (orphanedFiles.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const file of orphanedFiles) {
|
||||
await this.fileMetadataService
|
||||
.deleteFileById(file.id, file.workspaceId)
|
||||
.catch((error) => {
|
||||
throw new Error(
|
||||
`[${CleanupOrphanedFilesCronJob.name}] Cannot delete orphaned file - ${file.fullPath}: ${error.message}`,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -19,9 +19,6 @@ export class FileDTO {
|
||||
@Field()
|
||||
type: string;
|
||||
|
||||
@Field(() => UUIDScalarType, { nullable: true })
|
||||
messageId?: string;
|
||||
|
||||
@Field(() => Date, { nullable: false })
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
} from 'typeorm';
|
||||
|
||||
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { AgentChatMessageEntity } from 'src/engine/metadata-modules/agent/agent-chat-message.entity';
|
||||
|
||||
@Entity('file')
|
||||
@Index('IDX_FILE_WORKSPACE_ID', ['workspaceId'])
|
||||
@@ -39,15 +38,6 @@ export class FileEntity {
|
||||
@JoinColumn({ name: 'workspaceId' })
|
||||
workspace: Relation<Workspace>;
|
||||
|
||||
@Column({ nullable: true, type: 'uuid' })
|
||||
messageId: string;
|
||||
|
||||
@ManyToOne(() => AgentChatMessageEntity, {
|
||||
onDelete: 'CASCADE',
|
||||
})
|
||||
@JoinColumn({ name: 'messageId' })
|
||||
message: Relation<AgentChatMessageEntity>;
|
||||
|
||||
@CreateDateColumn({ type: 'timestamptz' })
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
@@ -11,8 +11,6 @@ import { JwtModule } from 'src/engine/core-modules/jwt/jwt.module';
|
||||
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
|
||||
import { FileController } from './controllers/file.controller';
|
||||
import { CleanupOrphanedFilesCronCommand } from './crons/commands/cleanup-orphaned-files.cron.command';
|
||||
import { CleanupOrphanedFilesCronJob } from './crons/jobs/cleanup-orphaned-files.cron.job';
|
||||
import { FileEntity } from './entities/file.entity';
|
||||
import { FileUploadService } from './file-upload/services/file-upload.service';
|
||||
import { FileResolver } from './resolvers/file.resolver';
|
||||
@@ -34,11 +32,9 @@ import { FileService } from './services/file.service';
|
||||
FileWorkspaceMemberListener,
|
||||
FileWorkspaceFolderDeletionJob,
|
||||
FileDeletionJob,
|
||||
CleanupOrphanedFilesCronJob,
|
||||
CleanupOrphanedFilesCronCommand,
|
||||
FileUploadService,
|
||||
],
|
||||
exports: [FileService, FileMetadataService, CleanupOrphanedFilesCronCommand],
|
||||
exports: [FileService, FileMetadataService],
|
||||
controllers: [FileController],
|
||||
})
|
||||
export class FileModule {}
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
Relation,
|
||||
} from 'typeorm';
|
||||
|
||||
import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity';
|
||||
import { AgentChatThreadEntity } from 'src/engine/metadata-modules/agent/agent-chat-thread.entity';
|
||||
|
||||
import { AgentChatMessagePartEntity } from './agent-chat-message-part.entity';
|
||||
@@ -41,9 +40,6 @@ export class AgentChatMessageEntity {
|
||||
@OneToMany(() => AgentChatMessagePartEntity, (part) => part.message)
|
||||
parts: Relation<AgentChatMessagePartEntity[]>;
|
||||
|
||||
@OneToMany(() => FileEntity, (file) => file.message)
|
||||
files: Relation<FileEntity[]>;
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
-4
@@ -1,7 +1,6 @@
|
||||
import { Field, ObjectType } from '@nestjs/graphql';
|
||||
|
||||
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
|
||||
import { FileDTO } from 'src/engine/core-modules/file/dtos/file.dto';
|
||||
|
||||
import { AgentChatMessagePartDTO } from './agent-chat-message-part.dto';
|
||||
|
||||
@@ -19,9 +18,6 @@ export class AgentChatMessageDTO {
|
||||
@Field(() => [AgentChatMessagePartDTO])
|
||||
parts: AgentChatMessagePartDTO[];
|
||||
|
||||
@Field(() => [FileDTO])
|
||||
files: FileDTO[];
|
||||
|
||||
@Field()
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user