AI tools to create a demo workspace (#18236)

This PR adds the necessary tool to create a demo workspace with :
relevant custom objects and fields, mock data and a real dashboard with
graph widgets.

It is still a bit under-optimized and slow but it works.

This PR also adds an AI tool that allows to see what happens in real
time, it navigates the app and waits when necessary.

---------

Co-authored-by: Félix Malfait <felix.malfait@gmail.com>
This commit is contained in:
Lucas Bordeau
2026-03-05 11:39:31 +01:00
committed by GitHub
parent a2f80d882b
commit 2a82df7073
84 changed files with 2449 additions and 315 deletions
File diff suppressed because one or more lines are too long
@@ -2,6 +2,12 @@ import { styled } from '@linaria/react';
import { type Editor } from '@tiptap/react';
import { AIChatSuggestedPrompts } from '@/ai/components/suggested-prompts/AIChatSuggestedPrompts';
import { agentChatErrorState } from '@/ai/states/agentChatErrorState';
import { agentChatHasMessageComponentSelector } from '@/ai/states/agentChatHasMessageComponentSelector';
import { agentChatIsLoadingState } from '@/ai/states/agentChatIsLoadingState';
import { useAtomComponentSelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentSelectorValue';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { isDefined } from 'twenty-shared/utils';
const StyledEmptyState = styled.div`
display: flex;
@@ -16,6 +22,21 @@ type AIChatEmptyStateProps = {
};
export const AIChatEmptyState = ({ editor }: AIChatEmptyStateProps) => {
const agentChatIsLoading = useAtomStateValue(agentChatIsLoadingState);
const agentChatError = useAtomStateValue(agentChatErrorState);
const hasMessages = useAtomComponentSelectorValue(
agentChatHasMessageComponentSelector,
);
const shouldRender =
!hasMessages && !isDefined(agentChatError) && !agentChatIsLoading;
if (!shouldRender) {
return null;
}
return (
<StyledEmptyState>
<AIChatSuggestedPrompts editor={editor} />
@@ -1,4 +1,7 @@
import { useAgentChatContextOrThrow } from '@/ai/hooks/useAgentChatContextOrThrow';
import { AGENT_CHAT_RETRY_EVENT_NAME } from '@/ai/constants/AgentChatRetryEventName';
import { agentChatIsStreamingState } from '@/ai/states/agentChatIsStreamingState';
import { dispatchBrowserEvent } from '@/browser-event/utils/dispatchBrowserEvent';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { styled } from '@linaria/react';
import { t } from '@lingui/core/macro';
import { useContext } from 'react';
@@ -47,8 +50,13 @@ type AIChatErrorMessageProps = {
};
export const AIChatErrorMessage = ({ error }: AIChatErrorMessageProps) => {
const agentChatIsStreaming = useAtomStateValue(agentChatIsStreamingState);
const handleRetryClick = () => {
dispatchBrowserEvent(AGENT_CHAT_RETRY_EVENT_NAME);
};
const { theme } = useContext(ThemeContext);
const { handleRetry, isStreaming } = useAgentChatContextOrThrow();
return (
<StyledErrorContainer>
@@ -65,8 +73,8 @@ export const AIChatErrorMessage = ({ error }: AIChatErrorMessageProps) => {
variant="secondary"
size="small"
Icon={IconRefresh}
onClick={handleRetry}
disabled={isStreaming}
onClick={handleRetryClick}
disabled={agentChatIsStreaming}
title={t`Retry`}
/>
</StyledErrorContainer>
@@ -0,0 +1,35 @@
import { AIChatStandaloneError } from '@/ai/components/AIChatStandaloneError';
import { AgentMessageRole } from '@/ai/constants/AgentMessageRole';
import { agentChatErrorState } from '@/ai/states/agentChatErrorState';
import { agentChatIsStreamingState } from '@/ai/states/agentChatIsStreamingState';
import { agentChatMessageComponentFamilySelector } from '@/ai/states/agentChatMessageComponentFamilySelector';
import { agentChatMessageIdsComponentSelector } from '@/ai/states/agentChatMessageIdsComponentSelector';
import { useAtomComponentFamilySelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentFamilySelectorValue';
import { useAtomComponentSelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentSelectorValue';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
export const AIChatErrorUnderMessageList = () => {
const agentChatError = useAtomStateValue(agentChatErrorState);
const agentChatIsStreaming = useAtomStateValue(agentChatIsStreamingState);
const agentChatMessageIds = useAtomComponentSelectorValue(
agentChatMessageIdsComponentSelector,
);
const lastMessageId = agentChatMessageIds.at(-1);
const agentChatMessage = useAtomComponentFamilySelectorValue(
agentChatMessageComponentFamilySelector,
{ messageId: lastMessageId },
);
const showError =
agentChatError &&
!agentChatIsStreaming &&
agentChatMessage?.role === AgentMessageRole.USER;
if (!showError) {
return null;
}
return <AIChatStandaloneError />;
};
@@ -5,9 +5,16 @@ import { AgentMessageRole } from '@/ai/constants/AgentMessageRole';
import { AIChatAssistantMessageRenderer } from '@/ai/components/AIChatAssistantMessageRenderer';
import { AIChatErrorRenderer } from '@/ai/components/AIChatErrorRenderer';
import { agentChatErrorState } from '@/ai/states/agentChatErrorState';
import { agentChatIsStreamingState } from '@/ai/states/agentChatIsStreamingState';
import { agentChatMessageComponentFamilySelector } from '@/ai/states/agentChatMessageComponentFamilySelector';
import { agentChatMessageIdsComponentSelector } from '@/ai/states/agentChatMessageIdsComponentSelector';
import { LightCopyIconButton } from '@/object-record/record-field/ui/components/LightCopyIconButton';
import { useAtomComponentFamilySelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentFamilySelectorValue';
import { useAtomComponentSelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentSelectorValue';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { isExtendedFileUIPart, type ExtendedUIMessage } from 'twenty-shared/ai';
import { isExtendedFileUIPart } from 'twenty-shared/ai';
import { isDefined } from 'twenty-shared/utils';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { dateLocaleState } from '~/localization/states/dateLocaleState';
@@ -136,31 +143,45 @@ const StyledFilesContainer = styled.div`
margin-top: ${themeCssVariables.spacing[2]};
`;
export const AIChatMessage = ({
message,
isLastMessageStreaming,
error,
}: {
message: ExtendedUIMessage;
isLastMessageStreaming: boolean;
error?: Error | null;
}) => {
export const AIChatMessage = ({ messageId }: { messageId: string }) => {
const agentChatMessage = useAtomComponentFamilySelectorValue(
agentChatMessageComponentFamilySelector,
{ messageId },
);
const agentChatMessageIds = useAtomComponentSelectorValue(
agentChatMessageIdsComponentSelector,
);
const agentChatIsStreaming = useAtomStateValue(agentChatIsStreamingState);
const agentChatError = useAtomStateValue(agentChatErrorState);
const { localeCatalog } = useAtomStateValue(dateLocaleState);
const isUser = message.role === AgentMessageRole.USER;
const showError =
isDefined(error) && message.role === AgentMessageRole.ASSISTANT;
if (!isDefined(agentChatMessage)) {
return null;
}
const fileParts = message.parts.filter(isExtendedFileUIPart);
const isLastMessage = agentChatMessageIds.at(-1) === messageId;
const isLastMessageStreaming = agentChatIsStreaming && isLastMessage;
const isLastAssistantMessage =
isLastMessage && agentChatMessage?.role === AgentMessageRole.ASSISTANT;
const shouldShowError = isDefined(agentChatError) && isLastAssistantMessage;
const isUser = agentChatMessage.role === AgentMessageRole.USER;
const fileParts = agentChatMessage.parts.filter(isExtendedFileUIPart);
return (
<StyledMessageBubble key={message.id} isUser={isUser}>
<StyledMessageBubble key={agentChatMessage.id} isUser={isUser}>
<StyledMessageContainer isUser={isUser}>
<StyledMessageText isUser={isUser}>
<AIChatAssistantMessageRenderer
isLastMessageStreaming={isLastMessageStreaming}
messageParts={message.parts}
hasError={showError}
messageParts={agentChatMessage.parts}
hasError={shouldShowError}
/>
</StyledMessageText>
{fileParts.length > 0 && (
@@ -170,23 +191,25 @@ export const AIChatMessage = ({
))}
</StyledFilesContainer>
)}
{showError && <AIChatErrorRenderer error={error} />}
{shouldShowError && <AIChatErrorRenderer error={agentChatError} />}
</StyledMessageContainer>
{message.parts.length > 0 && message.metadata?.createdAt && (
<StyledMessageFooter className="message-footer">
<StyledMessageTimestamp>
{beautifyPastDateRelativeToNow(
message.metadata?.createdAt,
localeCatalog,
)}
</StyledMessageTimestamp>
<LightCopyIconButton
copyText={
message.parts.find((part) => part.type === 'text')?.text ?? ''
}
/>
</StyledMessageFooter>
)}
{agentChatMessage.parts.length > 0 &&
agentChatMessage.metadata?.createdAt && (
<StyledMessageFooter className="message-footer">
<StyledMessageTimestamp>
{beautifyPastDateRelativeToNow(
agentChatMessage.metadata?.createdAt,
localeCatalog,
)}
</StyledMessageTimestamp>
<LightCopyIconButton
copyText={
agentChatMessage.parts.find((part) => part.type === 'text')
?.text ?? ''
}
/>
</StyledMessageFooter>
)}
</StyledMessageBubble>
);
};
@@ -1,6 +1,12 @@
import { styled } from '@linaria/react';
import { AIChatErrorRenderer } from '@/ai/components/AIChatErrorRenderer';
import { agentChatErrorState } from '@/ai/states/agentChatErrorState';
import { agentChatHasMessageComponentSelector } from '@/ai/states/agentChatHasMessageComponentSelector';
import { agentChatIsLoadingState } from '@/ai/states/agentChatIsLoadingState';
import { useAtomComponentSelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentSelectorValue';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { isDefined } from 'twenty-shared/utils';
const StyledErrorContainer = styled.div`
display: flex;
@@ -9,16 +15,25 @@ const StyledErrorContainer = styled.div`
width: 100%;
`;
type AIChatStandaloneErrorProps = {
error: Error;
};
export const AIChatStandaloneError = () => {
const agentChatIsLoading = useAtomStateValue(agentChatIsLoadingState);
const agentChatError = useAtomStateValue(agentChatErrorState);
const hasMessages = useAtomComponentSelectorValue(
agentChatHasMessageComponentSelector,
);
const shouldRender =
!hasMessages && isDefined(agentChatError) && !agentChatIsLoading;
if (!shouldRender) {
return null;
}
export const AIChatStandaloneError = ({
error,
}: AIChatStandaloneErrorProps) => {
return (
<StyledErrorContainer>
<AIChatErrorRenderer error={error} />
<AIChatErrorRenderer error={agentChatError} />
</StyledErrorContainer>
);
};
@@ -7,20 +7,16 @@ import { themeCssVariables } from 'twenty-ui/theme-constants';
import { DropZone } from '@/activities/files/components/DropZone';
import { AgentChatFileUploadButton } from '@/ai/components/internal/AgentChatFileUploadButton';
import { useAiModelLabel } from '@/ai/hooks/useAiModelOptions';
import { ScrollWrapper } from '@/ui/utilities/scroll/components/ScrollWrapper';
import { AIChatEmptyState } from '@/ai/components/AIChatEmptyState';
import { AIChatMessage } from '@/ai/components/AIChatMessage';
import { AIChatStandaloneError } from '@/ai/components/AIChatStandaloneError';
import { AIChatTabMessageList } from '@/ai/components/AIChatTabMessageList';
import { AIChatContextUsageButton } from '@/ai/components/internal/AIChatContextUsageButton';
import { AIChatSkeletonLoader } from '@/ai/components/internal/AIChatSkeletonLoader';
import { AgentChatContextPreview } from '@/ai/components/internal/AgentChatContextPreview';
import { SendMessageButton } from '@/ai/components/internal/SendMessageButton';
import { AgentMessageRole } from '@/ai/constants/AgentMessageRole';
import { AI_CHAT_SCROLL_WRAPPER_ID } from '@/ai/constants/AiChatScrollWrapperId';
import { useAIChatEditor } from '@/ai/hooks/useAIChatEditor';
import { useAIChatFileUpload } from '@/ai/hooks/useAIChatFileUpload';
import { useAgentChatContextOrThrow } from '@/ai/hooks/useAgentChatContextOrThrow';
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
@@ -100,16 +96,6 @@ const StyledEditorWrapper = styled.div`
}
`;
const StyledScrollWrapper = styled(ScrollWrapper)`
display: flex;
flex: 1;
flex-direction: column;
gap: ${themeCssVariables.spacing[2]};
overflow-y: auto;
padding: ${themeCssVariables.spacing[3]};
width: calc(100% - 24px) !important;
`;
const StyledButtonsContainer = styled.div`
align-items: center;
display: flex;
@@ -141,17 +127,12 @@ const StyledReadOnlyModelButton = styled(LightButton)`
export const AIChatTab = () => {
const [isDraggingFile, setIsDraggingFile] = useState(false);
const isMobile = useIsMobile();
const { isLoading, messages, isStreaming, error, handleSendMessage } =
useAgentChatContextOrThrow();
const hasMessages = messages.length > 0;
const { uploadFiles } = useAIChatFileUpload();
const currentWorkspace = useAtomStateValue(currentWorkspaceState);
const smartModelLabel = useAiModelLabel(currentWorkspace?.smartModel, false);
const { editor, handleSendAndClear } = useAIChatEditor({
onSendMessage: handleSendMessage,
});
const { editor, handleSendAndClear } = useAIChatEditor();
return (
<StyledContainer
@@ -166,41 +147,10 @@ export const AIChatTab = () => {
)}
{!isDraggingFile && (
<>
{hasMessages && (
<StyledScrollWrapper
componentInstanceId={AI_CHAT_SCROLL_WRAPPER_ID}
>
{messages.map((message, index) => {
const isLastMessage = index === messages.length - 1;
const isLastMessageStreaming = isStreaming && isLastMessage;
const isLastAssistantMessage =
isLastMessage && message.role === AgentMessageRole.ASSISTANT;
const shouldShowError = error && isLastAssistantMessage;
return (
<AIChatMessage
isLastMessageStreaming={isLastMessageStreaming}
message={message}
key={message.id}
error={shouldShowError ? error : null}
/>
);
})}
{error &&
!isStreaming &&
messages.at(-1)?.role === AgentMessageRole.USER && (
<AIChatStandaloneError error={error} />
)}
</StyledScrollWrapper>
)}
{!hasMessages && !error && !isLoading && (
<AIChatEmptyState editor={editor} />
)}
{!hasMessages && error && !isLoading && (
<AIChatStandaloneError error={error} />
)}
{isLoading && !hasMessages && <AIChatSkeletonLoader />}
<AIChatTabMessageList />
<AIChatEmptyState editor={editor} />
<AIChatStandaloneError />
<AIChatSkeletonLoader />
<StyledInputArea isMobile={isMobile}>
<AgentChatContextPreview />
<StyledInputBox>
@@ -210,7 +160,7 @@ export const AIChatTab = () => {
<StyledButtonsContainer>
<StyledLeftButtonsContainer>
<AgentChatFileUploadButton />
{hasMessages && <AIChatContextUsageButton />}
<AIChatContextUsageButton />
</StyledLeftButtonsContainer>
<StyledRightButtonsContainer>
<StyledReadOnlyModelButton
@@ -0,0 +1,40 @@
import { AIChatErrorUnderMessageList } from '@/ai/components/AIChatErrorUnderMessageList';
import { AIChatMessage } from '@/ai/components/AIChatMessage';
import { AI_CHAT_SCROLL_WRAPPER_ID } from '@/ai/constants/AiChatScrollWrapperId';
import { agentChatMessageIdsComponentSelector } from '@/ai/states/agentChatMessageIdsComponentSelector';
import { ScrollWrapper } from '@/ui/utilities/scroll/components/ScrollWrapper';
import { useAtomComponentSelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentSelectorValue';
import { styled } from '@linaria/react';
import { isNonEmptyArray } from '@sniptt/guards';
import { themeCssVariables } from 'twenty-ui/theme-constants';
const StyledScrollWrapper = styled(ScrollWrapper)`
display: flex;
flex: 1;
flex-direction: column;
gap: ${themeCssVariables.spacing[2]};
overflow-y: auto;
padding: ${themeCssVariables.spacing[3]};
width: calc(100% - 24px);
`;
export const AIChatTabMessageList = () => {
const agentChatMessageIdsComponent = useAtomComponentSelectorValue(
agentChatMessageIdsComponentSelector,
);
const hasMessages = isNonEmptyArray(agentChatMessageIdsComponent);
if (!hasMessages) {
return null;
}
return (
<StyledScrollWrapper componentInstanceId={AI_CHAT_SCROLL_WRAPPER_ID}>
{agentChatMessageIdsComponent.map((messageId) => {
return <AIChatMessage messageId={messageId} key={messageId} />;
})}
<AIChatErrorUnderMessageList />
</StyledScrollWrapper>
);
};
@@ -0,0 +1,81 @@
import { useAgentChat } from '@/ai/hooks/useAgentChat';
import { useAgentChatData } from '@/ai/hooks/useAgentChatData';
import { useAgentChatScrollToBottom } from '@/ai/hooks/useAgentChatScrollToBottom';
import { useProcessIncrementalStreamMessages } from '@/ai/hooks/useProcessIncrementalStreamMessages';
import { agentChatErrorState } from '@/ai/states/agentChatErrorState';
import { agentChatIsLoadingState } from '@/ai/states/agentChatIsLoadingState';
import { agentChatIsStreamingState } from '@/ai/states/agentChatIsStreamingState';
import { agentChatMessagesComponentState } from '@/ai/states/agentChatMessagesComponentState';
import { agentChatUISessionStartTimeState } from '@/ai/states/agentChatUISessionStartTimeState';
import { useAtomComponentState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentState';
import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState';
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
import { useEffect } from 'react';
import { Temporal } from 'temporal-polyfill';
export const AgentChatDataEffect = () => {
const { uiMessages, isLoading } = useAgentChatData();
const chatState = useAgentChat(uiMessages);
const combinedIsLoading = chatState.isLoading || isLoading;
const isStreaming = chatState.status === 'streaming';
const setAgentChatIsLoading = useSetAtomState(agentChatIsLoadingState);
const setAgentChatError = useSetAtomState(agentChatErrorState);
const [agentChatUISessionStartTime, setAgentChatUISessionStartTime] =
useAtomState(agentChatUISessionStartTimeState);
const [, setAgentChatMessages] = useAtomComponentState(
agentChatMessagesComponentState,
);
useEffect(() => {
setAgentChatMessages(chatState.messages);
}, [chatState.messages, setAgentChatMessages]);
useEffect(() => {
setAgentChatIsLoading(combinedIsLoading);
}, [combinedIsLoading, setAgentChatIsLoading]);
useEffect(() => {
setAgentChatError(chatState.error);
}, [chatState.error, setAgentChatError]);
useEffect(() => {
if (agentChatUISessionStartTime === null) {
setAgentChatUISessionStartTime(Temporal.Now.instant());
}
}, [agentChatUISessionStartTime, setAgentChatUISessionStartTime]);
const setAgentChatIsStreaming = useSetAtomState(agentChatIsStreamingState);
useEffect(() => {
setAgentChatIsStreaming(isStreaming);
}, [setAgentChatIsStreaming, isStreaming]);
const { scrollToBottom, isNearBottom } = useAgentChatScrollToBottom();
const { processIncrementalStreamMessages } =
useProcessIncrementalStreamMessages();
useEffect(() => {
if (chatState.messages.length === 0) {
return;
}
if (isNearBottom) {
scrollToBottom();
}
processIncrementalStreamMessages(chatState.messages);
}, [
chatState.messages,
scrollToBottom,
isNearBottom,
processIncrementalStreamMessages,
]);
return null;
};
@@ -1,39 +0,0 @@
import { useAgentChatScrollToBottom } from '@/ai/hooks/useAgentChatScrollToBottom';
import { useEffect, useState } from 'react';
import { type ExtendedUIMessage } from 'twenty-shared/ai';
import { isDeeplyEqual } from '~/utils/isDeeplyEqual';
export const AgentChatMessagesEffect = ({
messages,
}: {
messages: ExtendedUIMessage[];
}) => {
const { scrollToBottom, isNearBottom } = useAgentChatScrollToBottom();
const [, setPreviousMessages] = useState<ExtendedUIMessage[] | null>(null);
useEffect(() => {
setPreviousMessages((previousMessages) => {
if (
previousMessages !== null &&
isDeeplyEqual(previousMessages, messages)
) {
return previousMessages;
}
// We intentionally force this effect because the chat transport streams messages incrementally
// and the only reliable way to react to those chunks is through useEffect updates.
const isNewMessage =
previousMessages === null ||
messages.length !== previousMessages.length;
if (isNewMessage || isNearBottom) {
scrollToBottom();
}
return messages;
});
}, [messages, scrollToBottom, isNearBottom]);
return null;
};
@@ -1,33 +1,10 @@
import { AgentChatMessagesEffect } from '@/ai/components/AgentChatMessagesEffect';
import { AgentChatContext } from '@/ai/contexts/AgentChatContext';
import { useAgentChat } from '@/ai/hooks/useAgentChat';
import { useAgentChatData } from '@/ai/hooks/useAgentChatData';
import { AgentChatDataEffect } from '@/ai/components/AgentChatDataEffect';
import { AgentChatComponentInstanceContext } from '@/ai/states/AgentChatComponentInstanceContext';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { Suspense } from 'react';
import { FeatureFlagKey } from '~/generated-metadata/graphql';
const AgentChatProviderContent = ({
children,
}: {
children: React.ReactNode;
}) => {
const { uiMessages, isLoading } = useAgentChatData();
const chatState = useAgentChat(uiMessages);
const combinedIsLoading = chatState.isLoading || isLoading;
return (
<AgentChatContext.Provider
value={{
...chatState,
isLoading: combinedIsLoading,
}}
>
<AgentChatMessagesEffect messages={chatState.messages} />
{children}
</AgentChatContext.Provider>
);
};
export const AgentChatProvider = ({
children,
}: {
@@ -36,16 +13,17 @@ export const AgentChatProvider = ({
const isAiEnabled = useIsFeatureEnabled(FeatureFlagKey.IS_AI_ENABLED);
if (!isAiEnabled) {
return (
<AgentChatContext.Provider value={null}>
{children}
</AgentChatContext.Provider>
);
return <>{children}</>;
}
return (
<Suspense fallback={null}>
<AgentChatProviderContent>{children}</AgentChatProviderContent>
<AgentChatComponentInstanceContext.Provider
value={{ instanceId: 'agentChatComponentInstance' }}
>
<AgentChatDataEffect />
{children}
</AgentChatComponentInstanceContext.Provider>
</Suspense>
);
};
@@ -1,5 +1,6 @@
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { objectMetadataItemFamilySelector } from '@/object-metadata/states/objectMetadataItemFamilySelector';
import { getLinkToShowPage } from '@/object-metadata/utils/getLinkToShowPage';
import { useAtomFamilySelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomFamilySelectorValue';
import { t } from '@lingui/core/macro';
import { isNonEmptyString } from '@sniptt/guards';
import { AvatarOrIcon, ChipVariant, LinkChip } from 'twenty-ui/components';
@@ -15,9 +16,13 @@ export const RecordLink = ({
recordId,
displayName,
}: RecordLinkProps) => {
const { objectMetadataItem } = useObjectMetadataItem({
objectNameSingular,
});
const objectMetadataItem = useAtomFamilySelectorValue(
objectMetadataItemFamilySelector,
{
objectName: objectNameSingular,
objectNameType: 'singular',
},
);
if (!objectMetadataItem || !isNonEmptyString(recordId)) {
return <span>{displayName}</span>;
@@ -1,10 +1,20 @@
import { styled } from '@linaria/react';
import { type Meta, type StoryObj } from '@storybook/react-vite';
import {
type Decorator,
type Meta,
type StoryObj,
} from '@storybook/react-vite';
import { useEffect } from 'react';
import { userEvent, within } from 'storybook/test';
import { type ExtendedUIMessage } from 'twenty-shared/ai';
import { ComponentDecorator } from 'twenty-ui/testing';
import { AIChatMessage } from '@/ai/components/AIChatMessage';
import { AgentChatComponentInstanceContext } from '@/ai/states/AgentChatComponentInstanceContext';
import { agentChatMessageComponentFamilyState } from '@/ai/states/agentChatMessageComponentFamilyState';
import { agentChatMessagesComponentState } from '@/ai/states/agentChatMessagesComponentState';
import { styled } from '@linaria/react';
import { useStore } from 'jotai';
import { RootDecorator } from '~/testing/decorators/RootDecorator';
import { SnackBarDecorator } from '~/testing/decorators/SnackBarDecorator';
@@ -16,6 +26,8 @@ const StyledConversationContainer = styled.div`
padding: 24px;
`;
const INSTANCE_ID = 'agentChatStoryInstance';
// Mock messages for the conversation showcase
const mockUserMessage: ExtendedUIMessage = {
id: 'msg-user-1',
@@ -224,10 +236,62 @@ const mockThinkingStepsDone: ExtendedUIMessage = {
},
};
const allMockMessages = [
mockUserMessage,
mockAssistantWithCodeExecution,
mockSimpleTextResponse,
mockStreamingMessage,
mockCodeExecutionRunning,
mockCodeExecutionError,
mockThinkingStepsStreaming,
mockThinkingStepsDone,
];
const AgentChatMessagesSetterEffect = ({
messages,
}: {
messages: ExtendedUIMessage[];
}) => {
const store = useStore();
useEffect(() => {
store.set(
agentChatMessagesComponentState.atomFamily({ instanceId: INSTANCE_ID }),
messages,
);
for (const message of messages) {
store.set(
agentChatMessageComponentFamilyState.atomFamily({
instanceId: INSTANCE_ID,
familyKey: message.id,
}),
message,
);
}
}, [messages, store]);
return null;
};
const AgentChatInstanceDecorator: Decorator = (Story) => (
<AgentChatComponentInstanceContext.Provider
value={{ instanceId: INSTANCE_ID }}
>
<AgentChatMessagesSetterEffect messages={allMockMessages} />
<Story />
</AgentChatComponentInstanceContext.Provider>
);
const meta: Meta<typeof AIChatMessage> = {
title: 'Modules/AI/AIChatMessage',
component: AIChatMessage,
decorators: [ComponentDecorator, RootDecorator, SnackBarDecorator],
decorators: [
ComponentDecorator,
RootDecorator,
SnackBarDecorator,
AgentChatInstanceDecorator,
],
parameters: {
container: { width: 700 },
},
@@ -240,69 +304,42 @@ type Story = StoryObj<typeof AIChatMessage>;
export const ConversationWithCodeExecution: Story = {
render: () => (
<StyledConversationContainer>
<AIChatMessage message={mockUserMessage} isLastMessageStreaming={false} />
<AIChatMessage
message={mockAssistantWithCodeExecution}
isLastMessageStreaming={false}
/>
<AIChatMessage messageId={mockUserMessage.id} />
<AIChatMessage messageId={mockAssistantWithCodeExecution.id} />
</StyledConversationContainer>
),
};
export const UserMessage: Story = {
args: {
message: mockUserMessage,
isLastMessageStreaming: false,
},
render: () => <AIChatMessage messageId={mockUserMessage.id} />,
};
export const AssistantTextResponse: Story = {
args: {
message: mockSimpleTextResponse,
isLastMessageStreaming: false,
},
render: () => <AIChatMessage messageId={mockSimpleTextResponse.id} />,
};
export const AssistantStreaming: Story = {
args: {
message: mockStreamingMessage,
isLastMessageStreaming: true,
},
render: () => <AIChatMessage messageId={mockStreamingMessage.id} />,
};
export const CodeExecutionRunning: Story = {
args: {
message: mockCodeExecutionRunning,
isLastMessageStreaming: false,
},
render: () => <AIChatMessage messageId={mockCodeExecutionRunning.id} />,
};
export const CodeExecutionWithError: Story = {
args: {
message: mockCodeExecutionError,
isLastMessageStreaming: false,
},
render: () => <AIChatMessage messageId={mockCodeExecutionError.id} />,
};
export const ThinkingStepsThinkingState: Story = {
args: {
message: mockThinkingStepsStreaming,
isLastMessageStreaming: true,
},
render: () => <AIChatMessage messageId={mockThinkingStepsStreaming.id} />,
};
export const ThinkingStepsDoneCollapsed: Story = {
args: {
message: mockThinkingStepsDone,
isLastMessageStreaming: false,
},
render: () => <AIChatMessage messageId={mockThinkingStepsDone.id} />,
};
export const ThinkingStepsDoneExpanded: Story = {
args: {
message: mockThinkingStepsDone,
isLastMessageStreaming: false,
},
render: () => <AIChatMessage messageId={mockThinkingStepsDone.id} />,
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const summaryButton = await canvas.findByRole('button', {
@@ -9,11 +9,13 @@ import { ThemeContext } from 'twenty-ui/theme';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { ContextUsageProgressRing } from '@/ai/components/internal/ContextUsageProgressRing';
import { SettingsBillingLabelValueItem } from '@/billing/components/internal/SettingsBillingLabelValueItem';
import { agentChatHasMessageComponentSelector } from '@/ai/states/agentChatHasMessageComponentSelector';
import {
agentChatUsageState,
type AgentChatLastMessageUsage,
} from '@/ai/states/agentChatUsageState';
import { SettingsBillingLabelValueItem } from '@/billing/components/internal/SettingsBillingLabelValueItem';
import { useAtomComponentSelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentSelectorValue';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
const StyledContainer = styled.div`
@@ -118,6 +120,14 @@ export const AIChatContextUsageButton = () => {
const [isHovered, setIsHovered] = useState(false);
const agentChatUsage = useAtomStateValue(agentChatUsageState);
const hasMessages = useAtomComponentSelectorValue(
agentChatHasMessageComponentSelector,
);
if (!hasMessages) {
return null;
}
if (!agentChatUsage) {
return (
<StyledContainer>
@@ -1,3 +1,7 @@
import { agentChatHasMessageComponentSelector } from '@/ai/states/agentChatHasMessageComponentSelector';
import { agentChatIsLoadingState } from '@/ai/states/agentChatIsLoadingState';
import { useAtomComponentSelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentSelectorValue';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { styled } from '@linaria/react';
import { useContext } from 'react';
import Skeleton, { SkeletonTheme } from 'react-loading-skeleton';
@@ -27,6 +31,18 @@ const NUMBER_OF_SKELETONS = 6;
export const AIChatSkeletonLoader = () => {
const { theme } = useContext(ThemeContext);
const agentChatIsLoading = useAtomStateValue(agentChatIsLoadingState);
const hasMessages = useAtomComponentSelectorValue(
agentChatHasMessageComponentSelector,
);
const shouldRender = agentChatIsLoading && !hasMessages;
if (!shouldRender) {
return null;
}
return (
<SkeletonTheme
baseColor={theme.background.tertiary}
@@ -1,5 +1,8 @@
import { useAgentChatContextOrThrow } from '@/ai/hooks/useAgentChatContextOrThrow';
import { agentChatInputState } from '@/ai/states/agentChatInputState';
import { AGENT_CHAT_STOP_EVENT_NAME } from '@/ai/constants/AgentChatStopEventName';
import { agentChatInputIsEmptySelector } from '@/ai/states/agentChatInputIsEmptySelector';
import { agentChatIsLoadingState } from '@/ai/states/agentChatIsLoadingState';
import { agentChatIsStreamingState } from '@/ai/states/agentChatIsStreamingState';
import { dispatchBrowserEvent } from '@/browser-event/utils/dispatchBrowserEvent';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { IconArrowUp, IconPlayerStop } from 'twenty-ui/display';
import { RoundedIconButton } from 'twenty-ui/input';
@@ -9,15 +12,24 @@ type SendMessageButtonProps = {
};
export const SendMessageButton = ({ onSend }: SendMessageButtonProps) => {
const agentChatInput = useAtomStateValue(agentChatInputState);
const { handleStop, isLoading, isStreaming } = useAgentChatContextOrThrow();
const agentChatInputIsEmpty = useAtomStateValue(
agentChatInputIsEmptySelector,
);
if (isStreaming) {
const agentChatIsLoading = useAtomStateValue(agentChatIsLoadingState);
const agentChatIsStreaming = useAtomStateValue(agentChatIsStreamingState);
const handleStopClick = () => {
dispatchBrowserEvent(AGENT_CHAT_STOP_EVENT_NAME);
};
if (agentChatIsStreaming) {
return (
<RoundedIconButton
Icon={IconPlayerStop}
size="medium"
onClick={() => handleStop()}
onClick={handleStopClick}
/>
);
}
@@ -27,7 +39,7 @@ export const SendMessageButton = ({ onSend }: SendMessageButtonProps) => {
Icon={IconArrowUp}
size="medium"
onClick={onSend}
disabled={!agentChatInput || isLoading}
disabled={agentChatInputIsEmpty || agentChatIsLoading}
/>
);
};
@@ -2,6 +2,7 @@ import type { MessageDescriptor } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import {
type IconComponent,
IconJetpack,
IconLayoutDashboard,
IconPlus,
IconSettingsAutomation,
@@ -15,6 +16,14 @@ export type SuggestedPrompt = {
};
export const DEFAULT_SUGGESTED_PROMPTS: SuggestedPrompt[] = [
{
id: 'demo-workspace',
label: msg`Setup a tailor-made workspace`,
Icon: IconJetpack,
prefillPrompts: [
msg`Seed a demo workspace using the dedicated tool, ask me questions about my activity and goals to tailor the seeded data to my needs, and then help me explore the workspace and data you created.`,
],
},
{
id: 'dashboard',
label: msg`Create a dashboard`,
@@ -0,0 +1 @@
export const AGENT_CHAT_RETRY_EVENT_NAME = 'agent-chat-retry' as const;
@@ -0,0 +1,2 @@
export const AGENT_CHAT_SEND_MESSAGE_EVENT_NAME =
'agent-chat-send-message' as const;
@@ -0,0 +1 @@
export const AGENT_CHAT_STOP_EVENT_NAME = 'agent-chat-stop' as const;
@@ -1,17 +0,0 @@
import { createContext } from 'react';
import { type ExtendedUIMessage } from 'twenty-shared/ai';
export type AgentChatContextValue = {
messages: ExtendedUIMessage[];
isStreaming: boolean;
isLoading: boolean;
error?: Error;
handleSendMessage: () => Promise<void>;
handleStop: () => void;
handleRetry: () => void;
};
export const AgentChatContext = createContext<AgentChatContextValue | null>(
null,
);
@@ -10,6 +10,7 @@ import { isDefined } from 'twenty-shared/utils';
import { AI_CHAT_INPUT_ID } from '@/ai/constants/AiChatInputId';
import { agentChatInputState } from '@/ai/states/agentChatInputState';
import { dispatchAgentChatSendMessageEvent } from '@/ai/utils/dispatchAgentChatSendMessageEvent';
import { MENTION_SUGGESTION_PLUGIN_KEY } from '@/mention/constants/MentionSuggestionPluginKey';
import { MentionSuggestion } from '@/mention/extensions/MentionSuggestion';
import { MentionTag } from '@/mention/extensions/MentionTag';
@@ -20,11 +21,7 @@ import { FocusComponentType } from '@/ui/utilities/focus/types/FocusComponentTyp
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
import { turnIntoEmptyStringIfWhitespacesOnly } from '~/utils/string/turnIntoEmptyStringIfWhitespacesOnly';
type UseAIChatEditorProps = {
onSendMessage: () => void;
};
export const useAIChatEditor = ({ onSendMessage }: UseAIChatEditorProps) => {
export const useAIChatEditor = () => {
const setAgentChatInput = useSetAtomState(agentChatInputState);
const { searchMentionRecords } = useMentionSearch();
const { pushFocusItemToFocusStack } = usePushFocusItemToFocusStack();
@@ -61,7 +58,7 @@ export const useAIChatEditor = ({ onSendMessage }: UseAIChatEditorProps) => {
}
event.preventDefault();
onSendMessage();
dispatchAgentChatSendMessageEvent();
const { state } = view;
view.dispatch(state.tr.delete(0, state.doc.content.size));
@@ -108,9 +105,9 @@ export const useAIChatEditor = ({ onSendMessage }: UseAIChatEditorProps) => {
}
const handleSendAndClear = useCallback(() => {
onSendMessage();
dispatchAgentChatSendMessageEvent();
editor?.commands.clearContent();
}, [onSendMessage, editor]);
}, [editor]);
return { editor, handleSendAndClear };
};
@@ -1,3 +1,4 @@
import { AGENT_CHAT_SEND_MESSAGE_EVENT_NAME } from '@/ai/constants/AgentChatSendMessageEventName';
import { useApolloClient } from '@apollo/client';
import { useGetBrowsingContext } from '@/ai/hooks/useBrowsingContext';
@@ -7,16 +8,20 @@ import { agentChatUsageState } from '@/ai/states/agentChatUsageState';
import { currentAIChatThreadState } from '@/ai/states/currentAIChatThreadState';
import { currentAIChatThreadTitleState } from '@/ai/states/currentAIChatThreadTitleState';
import { AGENT_CHAT_RETRY_EVENT_NAME } from '@/ai/constants/AgentChatRetryEventName';
import { AGENT_CHAT_STOP_EVENT_NAME } from '@/ai/constants/AgentChatStopEventName';
import { agentChatInputState } from '@/ai/states/agentChatInputState';
import { REST_API_BASE_URL } from '@/apollo/constant/rest-api-base-url';
import { getTokenPair } from '@/apollo/utils/getTokenPair';
import { renewToken } from '@/auth/services/AuthService';
import { tokenPairState } from '@/auth/states/tokenPairState';
import { useListenToBrowserEvent } from '@/browser-event/hooks/useListenToBrowserEvent';
import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
import { useChat } from '@ai-sdk/react';
import { DefaultChatTransport } from 'ai';
import { useCallback } from 'react';
import { type ExtendedUIMessage } from 'twenty-shared/ai';
import { isDefined } from 'twenty-shared/utils';
import { REACT_APP_SERVER_BASE_URL } from '~/config';
@@ -183,15 +188,15 @@ export const useAgentChat = (uiMessages: ExtendedUIMessage[]) => {
});
const isStreaming = status === 'streaming';
const isLoading = isStreaming || agentChatSelectedFiles.length > 0;
const handleSendMessage = async () => {
const handleSendMessage = useCallback(async () => {
if (agentChatInput.trim() === '' || isLoading || !currentAIChatThread) {
return;
}
const content = agentChatInput.trim();
setAgentChatInput('');
const browsingContext = getBrowsingContext();
@@ -208,16 +213,40 @@ export const useAgentChat = (uiMessages: ExtendedUIMessage[]) => {
},
},
);
setAgentChatUploadedFiles([]);
};
}, [
agentChatInput,
isLoading,
currentAIChatThread,
setAgentChatInput,
getBrowsingContext,
sendMessage,
agentChatUploadedFiles,
setAgentChatUploadedFiles,
]);
useListenToBrowserEvent({
eventName: AGENT_CHAT_SEND_MESSAGE_EVENT_NAME,
onBrowserEvent: handleSendMessage,
});
useListenToBrowserEvent({
eventName: AGENT_CHAT_STOP_EVENT_NAME,
onBrowserEvent: stop,
});
useListenToBrowserEvent({
eventName: AGENT_CHAT_RETRY_EVENT_NAME,
onBrowserEvent: regenerate,
});
return {
messages,
handleSendMessage,
handleStop: stop,
isLoading,
isStreaming,
error,
handleRetry: regenerate,
status,
};
};
@@ -1,14 +0,0 @@
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;
};
@@ -29,6 +29,7 @@ export const useCreateNewAIChatThread = () => {
setCurrentAIChatThread(data.createChatThread.id);
setCurrentAIChatThreadTitle(null);
setAgentChatUsage(null);
openAskAIPage({ resetNavigationStack: false });
const newThread = data.createChatThread;
@@ -0,0 +1,57 @@
import { useProcessNewMessageStreamIncrement } from '@/ai/hooks/useProcessNewMessageStreamIncrement';
import { agentChatMessageComponentFamilyState } from '@/ai/states/agentChatMessageComponentFamilyState';
import { useAtomComponentFamilyStateCallbackState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentFamilyStateCallbackState';
import { jotaiStore } from '@/ui/utilities/state/jotai/jotaiStore';
import { cloneDeep } from '@apollo/client/utilities';
import { useCallback } from 'react';
import { type ExtendedUIMessage } from 'twenty-shared/ai';
import { isDefined } from 'twenty-shared/utils';
import { isDeeplyEqual } from '~/utils/isDeeplyEqual';
export const useProcessIncrementalStreamMessages = () => {
const agentChatMessageFamilyCallbackState =
useAtomComponentFamilyStateCallbackState(
agentChatMessageComponentFamilyState,
);
const { processNewMessageStreamIncrement } =
useProcessNewMessageStreamIncrement();
const processIncrementalStreamMessages = useCallback(
(incrementalStreamMessages: ExtendedUIMessage[]) => {
for (const updatedMessage of incrementalStreamMessages) {
const alreadyExistingMessage = jotaiStore.get(
agentChatMessageFamilyCallbackState(updatedMessage.id),
);
const messageContentHasChanged = !isDeeplyEqual(
alreadyExistingMessage,
updatedMessage,
);
const messageAlreadyExists = isDefined(alreadyExistingMessage);
const shouldProcessMessage =
!messageAlreadyExists || messageContentHasChanged;
if (!shouldProcessMessage) {
continue;
}
const clonedMessage = cloneDeep(updatedMessage);
jotaiStore.set(
agentChatMessageFamilyCallbackState(updatedMessage.id),
clonedMessage,
);
processNewMessageStreamIncrement(updatedMessage);
}
},
[agentChatMessageFamilyCallbackState, processNewMessageStreamIncrement],
);
return {
processIncrementalStreamMessages,
};
};
@@ -0,0 +1,47 @@
import { useProcessUIToolCallMessage } from '@/ai/hooks/useProcessUIToolCallMessage';
import { agentChatUISessionStartTimeState } from '@/ai/states/agentChatUISessionStartTimeState';
import { isUIToolCallMessage } from '@/ai/utils/isUIToolCallMessage';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { isNonEmptyString } from '@sniptt/guards';
import { Temporal } from 'temporal-polyfill';
import { type ExtendedUIMessage } from 'twenty-shared/ai';
export const useProcessNewMessageStreamIncrement = () => {
const agentChatUISessionStartTime = useAtomStateValue(
agentChatUISessionStartTimeState,
);
const { processUIToolCallMessage } = useProcessUIToolCallMessage();
const processNewMessageStreamIncrement = (
messageStreamIncrement: ExtendedUIMessage,
) => {
if (agentChatUISessionStartTime === null) {
return false;
}
const messageCreatedAt = messageStreamIncrement.metadata?.createdAt;
if (isNonEmptyString(messageCreatedAt)) {
const messageCreatedAtInstant = Temporal.Instant.from(messageCreatedAt);
const messageIsAfterChatSessionStart =
messageCreatedAtInstant.epochNanoseconds >=
agentChatUISessionStartTime.epochNanoseconds;
if (!messageIsAfterChatSessionStart) {
return false;
}
}
const messageIsUIToolCall = isUIToolCallMessage(messageStreamIncrement);
if (messageIsUIToolCall) {
processUIToolCallMessage(messageStreamIncrement);
}
};
return {
processNewMessageStreamIncrement,
};
};
@@ -0,0 +1,99 @@
import { processedToolExecutionPartIdsComponentState } from '@/ai/states/processedToolExecutionPartIdsComponentState';
import { extractUIToolCallParts } from '@/ai/utils/extractUIToolCallParts';
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
import { useAtomComponentStateCallbackState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateCallbackState';
import { useStore } from 'jotai';
import { type ExtendedUIMessage } from 'twenty-shared/ai';
import { AppPath } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { useNavigateApp } from '~/hooks/useNavigateApp';
import { sleep } from '~/utils/sleep';
export const useProcessUIToolCallMessage = () => {
const navigateApp = useNavigateApp();
const { objectMetadataItems } = useObjectMetadataItems();
const processedToolExecutionPartIdsCallbackState =
useAtomComponentStateCallbackState(
processedToolExecutionPartIdsComponentState,
);
const store = useStore();
const processUIToolCallMessage = async (
uiToolCallMessage: ExtendedUIMessage,
) => {
const uiToolCallMessageParts = extractUIToolCallParts(
uiToolCallMessage.parts,
);
const alreadyProcessedToolExecutionPartIds = store.get(
processedToolExecutionPartIdsCallbackState,
);
const toolCallMessagePartsToProcess = uiToolCallMessageParts.filter(
(part) => !alreadyProcessedToolExecutionPartIds.includes(part.toolCallId),
);
for (const toolExecutionPart of toolCallMessagePartsToProcess) {
if (!isDefined(toolExecutionPart.output)) {
continue;
}
if (toolExecutionPart.output.result.success !== true) {
continue;
}
store.set(processedToolExecutionPartIdsCallbackState, [
...alreadyProcessedToolExecutionPartIds,
toolExecutionPart.toolCallId,
]);
const navigateAppOutput = toolExecutionPart.output.result.result;
switch (navigateAppOutput.action) {
case 'navigateToObject': {
const objectNamePlural = objectMetadataItems.find(
(item) =>
item.nameSingular === navigateAppOutput.objectNameSingular,
)?.namePlural;
if (!isDefined(objectNamePlural)) {
throw new Error(
`Object with singular name ${navigateAppOutput.objectNameSingular} not found, cannot navigate to object page from chat.`,
);
}
navigateApp(AppPath.RecordIndexPage, {
objectNamePlural: objectNamePlural,
});
break;
}
case 'navigateToRecord': {
navigateApp(AppPath.RecordShowPage, {
objectNameSingular: navigateAppOutput.objectNameSingular,
objectRecordId: navigateAppOutput.recordId,
});
break;
}
case 'navigateToView':
// TODO: implement
break;
case 'wait': {
await sleep(navigateAppOutput.durationMs);
break;
}
default:
break;
}
}
};
return {
processUIToolCallMessage,
};
};
@@ -0,0 +1,4 @@
import { createComponentInstanceContext } from '@/ui/utilities/state/component-state/utils/createComponentInstanceContext';
export const AgentChatComponentInstanceContext =
createComponentInstanceContext();
@@ -0,0 +1,6 @@
import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState';
export const agentChatErrorState = createAtomState<Error | undefined | null>({
key: 'agentChatErrorState',
defaultValue: null,
});
@@ -0,0 +1,17 @@
import { AgentChatComponentInstanceContext } from '@/ai/states/AgentChatComponentInstanceContext';
import { agentChatMessagesComponentState } from '@/ai/states/agentChatMessagesComponentState';
import { createAtomComponentSelector } from '@/ui/utilities/state/jotai/utils/createAtomComponentSelector';
import { isNonEmptyArray } from '@sniptt/guards';
export const agentChatHasMessageComponentSelector =
createAtomComponentSelector<boolean>({
key: 'agentChatHasMessageComponentSelector',
componentInstanceContext: AgentChatComponentInstanceContext,
get:
({ instanceId }) =>
({ get }) => {
const messages = get(agentChatMessagesComponentState, { instanceId });
return isNonEmptyArray(messages);
},
});
@@ -0,0 +1,12 @@
import { agentChatInputState } from '@/ai/states/agentChatInputState';
import { createAtomSelector } from '@/ui/utilities/state/jotai/utils/createAtomSelector';
import { isNonEmptyString } from '@sniptt/guards';
export const agentChatInputIsEmptySelector = createAtomSelector<boolean>({
key: 'agentChatInputIsEmptySelector',
get: ({ get }) => {
const agentChatInput = get(agentChatInputState);
return !isNonEmptyString(agentChatInput);
},
});
@@ -0,0 +1,6 @@
import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState';
export const agentChatIsLoadingState = createAtomState({
key: 'agentChatIsLoadingState',
defaultValue: false,
});
@@ -0,0 +1,6 @@
import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState';
export const agentChatIsStreamingState = createAtomState({
key: 'agentChatIsStreamingState',
defaultValue: false,
});
@@ -0,0 +1,21 @@
import { AgentChatComponentInstanceContext } from '@/ai/states/AgentChatComponentInstanceContext';
import { agentChatMessagesComponentState } from '@/ai/states/agentChatMessagesComponentState';
import { createAtomComponentFamilySelector } from '@/ui/utilities/state/jotai/utils/createAtomComponentFamilySelector';
import { type ExtendedUIMessage } from 'twenty-shared/ai';
import { type Nullable } from 'twenty-shared/types';
export const agentChatMessageComponentFamilySelector =
createAtomComponentFamilySelector<
Nullable<ExtendedUIMessage>,
{ messageId: Nullable<string> }
>({
key: 'agentChatMessageComponentFamilySelector',
get:
({ instanceId, familyKey: { messageId } }) =>
({ get }) => {
const messages = get(agentChatMessagesComponentState, { instanceId });
return messages.find((message) => message.id === messageId);
},
componentInstanceContext: AgentChatComponentInstanceContext,
});
@@ -0,0 +1,10 @@
import { AgentChatComponentInstanceContext } from '@/ai/states/AgentChatComponentInstanceContext';
import { createAtomComponentFamilyState } from '@/ui/utilities/state/jotai/utils/createAtomComponentFamilyState';
import { type ExtendedUIMessage } from 'twenty-shared/ai';
export const agentChatMessageComponentFamilyState =
createAtomComponentFamilyState<ExtendedUIMessage | null, string>({
key: 'agentChatMessageComponentFamilyState',
defaultValue: null,
componentInstanceContext: AgentChatComponentInstanceContext,
});
@@ -0,0 +1,17 @@
import { AgentChatComponentInstanceContext } from '@/ai/states/AgentChatComponentInstanceContext';
import { agentChatMessagesComponentState } from '@/ai/states/agentChatMessagesComponentState';
import { createAtomComponentSelector } from '@/ui/utilities/state/jotai/utils/createAtomComponentSelector';
export const agentChatMessageIdsComponentSelector = createAtomComponentSelector<
string[]
>({
key: 'agentChatMessageIdsComponentSelector',
componentInstanceContext: AgentChatComponentInstanceContext,
get:
({ instanceId }) =>
({ get }) => {
const messages = get(agentChatMessagesComponentState, { instanceId });
return messages.map((message) => message.id);
},
});
@@ -1,14 +1,11 @@
import { createComponentInstanceContext } from '@/ui/utilities/state/component-state/utils/createComponentInstanceContext';
import { AgentChatComponentInstanceContext } from '@/ai/states/AgentChatComponentInstanceContext';
import { createAtomComponentState } from '@/ui/utilities/state/jotai/utils/createAtomComponentState';
import { type AgentMessage } from '~/generated-metadata/graphql';
export const AgentChatMessagesComponentInstanceContext =
createComponentInstanceContext();
import { type ExtendedUIMessage } from 'twenty-shared/ai';
export const agentChatMessagesComponentState = createAtomComponentState<
AgentMessage[]
ExtendedUIMessage[]
>({
key: 'agentChatMessagesComponentState',
defaultValue: [],
componentInstanceContext: AgentChatMessagesComponentInstanceContext,
componentInstanceContext: AgentChatComponentInstanceContext,
});
@@ -0,0 +1,8 @@
import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState';
import { type Temporal } from 'temporal-polyfill';
export const agentChatUISessionStartTimeState =
createAtomState<Temporal.Instant | null>({
key: 'agentChatUISessionStartTimeState',
defaultValue: null,
});
@@ -0,0 +1,9 @@
import { AgentChatComponentInstanceContext } from '@/ai/states/AgentChatComponentInstanceContext';
import { createAtomComponentState } from '@/ui/utilities/state/jotai/utils/createAtomComponentState';
export const processedToolExecutionPartIdsComponentState =
createAtomComponentState<string[]>({
key: 'processedToolExecutionPartIdsComponentState',
defaultValue: [],
componentInstanceContext: AgentChatComponentInstanceContext,
});
@@ -0,0 +1,15 @@
import { type NavigateAppToolOutput } from 'twenty-shared/ai';
export type AgentChatMessageUIToolCallPart = {
type: 'tool-execute_tool';
toolCallId: string;
state: string;
output: {
toolName: string;
result: {
message: string;
result: NavigateAppToolOutput;
success: boolean;
};
};
};
@@ -0,0 +1,5 @@
import { AGENT_CHAT_SEND_MESSAGE_EVENT_NAME } from '@/ai/constants/AgentChatSendMessageEventName';
export const dispatchAgentChatSendMessageEvent = () => {
window.dispatchEvent(new CustomEvent(AGENT_CHAT_SEND_MESSAGE_EVENT_NAME));
};
@@ -0,0 +1,14 @@
import { type AgentChatMessageUIToolCallPart } from '@/ai/types/AgentChatMessageUIToolCallPart';
import { type UIMessagePart } from 'ai';
export const extractUIToolCallParts = (
messageParts: UIMessagePart<any, any>[],
): AgentChatMessageUIToolCallPart[] => {
const uiToolCallParts = messageParts.filter(
(probablePart) =>
probablePart.type === 'tool-execute_tool' &&
probablePart.input?.toolName === 'navigate_app',
) as unknown as AgentChatMessageUIToolCallPart[];
return uiToolCallParts;
};
@@ -0,0 +1,9 @@
import { type ExtendedUIMessage } from 'twenty-shared/ai';
export const isUIToolCallMessage = (message: ExtendedUIMessage) => {
return message.parts.some(
(part) =>
part.type === 'tool-execute_tool' &&
(part.input as any)?.toolName === 'navigate_app',
);
};
@@ -12,5 +12,6 @@ export const mapDBMessagesToUIMessages = (
metadata: {
createdAt: dbMessage.createdAt,
},
threadId: dbMessage.threadId,
}));
};
@@ -12,6 +12,7 @@ import { isCaptchaScriptLoadedState } from '@/captcha/states/isCaptchaScriptLoad
import { isCaptchaRequiredForPath } from '@/captcha/utils/isCaptchaRequiredForPath';
import { useCommandMenu } from '@/command-menu/hooks/useCommandMenu';
import { commandMenuPageState } from '@/command-menu/states/commandMenuPageState';
import { isCommandMenuOpenedState } from '@/command-menu/states/isCommandMenuOpenedState';
import { MAIN_CONTEXT_STORE_INSTANCE_ID } from '@/context-store/constants/MainContextStoreInstanceId';
import { contextStoreCurrentViewIdComponentState } from '@/context-store/states/contextStoreCurrentViewIdComponentState';
import { contextStoreCurrentViewTypeComponentState } from '@/context-store/states/contextStoreCurrentViewTypeComponentState';
@@ -115,11 +116,19 @@ export const PageChangeEffect = () => {
isMatchingLocation(location, appPath),
);
const closeCommandMenuUnlessOnEditPage = useCallback(() => {
const closeCommandMenuUnlessNotRelevant = useCallback(() => {
const currentPage = store.get(commandMenuPageState.atom);
if (currentPage === CommandMenuPages.NavigationMenuItemEdit) {
return;
}
const commandMenuIsAiChat = currentPage === CommandMenuPages.AskAI;
if (commandMenuIsAiChat) {
return;
}
closeCommandMenu();
}, [closeCommandMenu, store]);
@@ -130,8 +139,8 @@ export const PageChangeEffect = () => {
const { openNewRecordTitleCell } = useOpenNewRecordTitleCell();
useEffect(() => {
closeCommandMenuUnlessOnEditPage();
}, [location.pathname, closeCommandMenuUnlessOnEditPage]);
closeCommandMenuUnlessNotRelevant();
}, [location.pathname, closeCommandMenuUnlessNotRelevant]);
useEffect(() => {
if (!previousLocation || previousLocation !== location.pathname) {
@@ -207,6 +216,24 @@ export const PageChangeEffect = () => {
break;
}
case isMatchingLocation(location, AppPath.RecordShowPage): {
const isNewRecord = location.state?.isNewRecord === true;
if (
isNewRecord &&
isDefined(location.state?.labelIdentifierFieldName)
) {
openNewRecordTitleCell({
recordId: location.state.objectRecordId,
fieldName: location.state.labelIdentifierFieldName,
});
}
const isCommandMenuOpen = store.get(isCommandMenuOpenedState.atom);
if (isCommandMenuOpen) {
return;
}
resetFocusStackToFocusItem({
focusStackItem: {
focusId: PageFocusId.RecordShowPage,
@@ -220,18 +247,6 @@ export const PageChangeEffect = () => {
},
},
});
const isNewRecord = location.state?.isNewRecord === true;
if (
isNewRecord &&
isDefined(location.state?.labelIdentifierFieldName)
) {
openNewRecordTitleCell({
recordId: location.state.objectRecordId,
fieldName: location.state.labelIdentifierFieldName,
});
}
break;
}
case isMatchingLocation(location, AppPath.SignInUp): {
@@ -376,6 +391,7 @@ export const PageChangeEffect = () => {
resetFocusStackToRecordIndex,
resetFocusStackToFocusItem,
openNewRecordTitleCell,
store,
]);
useEffect(() => {
@@ -0,0 +1,26 @@
import { useEffect } from 'react';
export const useListenToBrowserEvent = <T>({
onBrowserEvent,
eventName,
}: {
onBrowserEvent: (detail?: T) => void;
eventName: string;
}) => {
useEffect(() => {
const handleWindowDOMEvent = (event: CustomEvent<T>) => {
const detail = event.detail;
onBrowserEvent(detail);
};
window.addEventListener(eventName, handleWindowDOMEvent as EventListener);
return () => {
window.removeEventListener(
eventName,
handleWindowDOMEvent as EventListener,
);
};
}, [eventName, onBrowserEvent]);
};
@@ -0,0 +1,9 @@
import { isDefined } from 'twenty-shared/utils';
export const dispatchBrowserEvent = <T>(eventName: string, detail?: T) => {
if (isDefined(detail)) {
window.dispatchEvent(new CustomEvent<T>(eventName, { detail }));
} else {
window.dispatchEvent(new CustomEvent(eventName));
}
};
@@ -0,0 +1,27 @@
import { useListenToMetadataOperationBrowserEvent } from '@/browser-event/hooks/useListenToMetadataOperationBrowserEvent';
import { useRefreshPageLayouts } from '@/page-layout/hooks/useRefreshPageLayouts';
import { useListenToEventsForQuery } from '@/sse-db-event/hooks/useListenToEventsForQuery';
import { AllMetadataName } from '~/generated-metadata/graphql';
export const PageLayoutSSEEffect = () => {
const queryId = 'page-layout-metadata-sse-effect';
const { refreshPageLayouts } = useRefreshPageLayouts();
useListenToEventsForQuery({
queryId,
operationSignature: {
metadataName: AllMetadataName.pageLayout,
variables: {},
},
});
useListenToMetadataOperationBrowserEvent({
metadataName: AllMetadataName.pageLayout,
onMetadataOperationBrowserEvent: () => {
refreshPageLayouts();
},
});
return null;
};
@@ -0,0 +1,27 @@
import { useListenToMetadataOperationBrowserEvent } from '@/browser-event/hooks/useListenToMetadataOperationBrowserEvent';
import { useRefreshPageLayouts } from '@/page-layout/hooks/useRefreshPageLayouts';
import { useListenToEventsForQuery } from '@/sse-db-event/hooks/useListenToEventsForQuery';
import { AllMetadataName } from '~/generated-metadata/graphql';
export const PageLayoutTabSSEEffect = () => {
const queryId = 'page-layout-tab-metadata-sse-effect';
const { refreshPageLayouts } = useRefreshPageLayouts();
useListenToEventsForQuery({
queryId,
operationSignature: {
metadataName: AllMetadataName.pageLayoutTab,
variables: {},
},
});
useListenToMetadataOperationBrowserEvent({
metadataName: AllMetadataName.pageLayoutTab,
onMetadataOperationBrowserEvent: () => {
refreshPageLayouts();
},
});
return null;
};
@@ -0,0 +1,27 @@
import { useListenToMetadataOperationBrowserEvent } from '@/browser-event/hooks/useListenToMetadataOperationBrowserEvent';
import { useRefreshPageLayouts } from '@/page-layout/hooks/useRefreshPageLayouts';
import { useListenToEventsForQuery } from '@/sse-db-event/hooks/useListenToEventsForQuery';
import { AllMetadataName } from '~/generated-metadata/graphql';
export const PageLayoutWidgetSSEEffect = () => {
const queryId = 'page-layout-widget-metadata-sse-effect';
const { refreshPageLayouts } = useRefreshPageLayouts();
useListenToEventsForQuery({
queryId,
operationSignature: {
metadataName: AllMetadataName.pageLayoutWidget,
variables: {},
},
});
useListenToMetadataOperationBrowserEvent({
metadataName: AllMetadataName.pageLayoutWidget,
onMetadataOperationBrowserEvent: () => {
refreshPageLayouts();
},
});
return null;
};
@@ -7,10 +7,10 @@ import { getObjectMetadataForNavigationMenuItem } from '@/navigation-menu-item/u
import { isNavigationMenuItemFolder } from '@/navigation-menu-item/utils/isNavigationMenuItemFolder';
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { coreViewsState } from '@/views/states/coreViewState';
import { convertCoreViewToView } from '@/views/utils/convertCoreViewToView';
import { isDefined } from 'twenty-shared/utils';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useNavigationMenuItemsByFolder } from './useNavigationMenuItemsByFolder';
import { usePrefetchedNavigationMenuItemsData } from './usePrefetchedNavigationMenuItemsData';
@@ -25,13 +25,13 @@ import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadat
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { getObjectPermissionsForObject } from '@/object-metadata/utils/getObjectPermissionsForObject';
import { useObjectPermissions } from '@/object-record/hooks/useObjectPermissions';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { DraggableItem } from '@/ui/layout/draggable-list/components/DraggableItem';
import { NavigationDrawerAnimatedCollapseWrapper } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerAnimatedCollapseWrapper';
import { NavigationDrawerItem } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerItem';
import { NavigationDrawerSection } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerSection';
import { NavigationDrawerSectionTitle } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerSectionTitle';
import { useNavigationSection } from '@/ui/navigation/navigation-drawer/hooks/useNavigationSection';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { coreViewsState } from '@/views/states/coreViewState';
import { convertCoreViewToView } from '@/views/utils/convertCoreViewToView';
@@ -1,11 +1,19 @@
import { isCommandMenuOpenedState } from '@/command-menu/states/isCommandMenuOpenedState';
import { PageFocusId } from '@/types/PageFocusId';
import { useResetFocusStackToFocusItem } from '@/ui/utilities/focus/hooks/useResetFocusStackToFocusItem';
import { FocusComponentType } from '@/ui/utilities/focus/types/FocusComponentType';
import { useStore } from 'jotai';
export const useResetFocusStackToRecordIndex = () => {
const { resetFocusStackToFocusItem } = useResetFocusStackToFocusItem();
const store = useStore();
const resetFocusStackToRecordIndex = () => {
const isCommandMenuOpen = store.get(isCommandMenuOpenedState.atom);
const shouldEnableGlobalHotkeys = !isCommandMenuOpen;
resetFocusStackToFocusItem({
focusStackItem: {
focusId: PageFocusId.RecordIndex,
@@ -14,8 +22,8 @@ export const useResetFocusStackToRecordIndex = () => {
componentInstanceId: PageFocusId.RecordIndex,
},
globalHotkeysConfig: {
enableGlobalHotkeysWithModifiers: true,
enableGlobalHotkeysConflictingWithKeyboard: true,
enableGlobalHotkeysWithModifiers: shouldEnableGlobalHotkeys,
enableGlobalHotkeysConflictingWithKeyboard: shouldEnableGlobalHotkeys,
},
},
});
@@ -1,11 +1,12 @@
import { RecordTableDeactivateRecordTableRowEffect } from '@/object-record/record-table/components/RecordTableDeactivateRecordTableRowEffect';
import { RecordTableEmptyHasNewRecordEffect } from '@/object-record/record-table/components/RecordTableEmptyHasNewRecordEffect';
import { RecordTableBodyEscapeHotkeyEffect } from '@/object-record/record-table/record-table-body/components/RecordTableBodyEscapeHotkeyEffect';
import { RecordTableBodyFocusClickOutsideEffect } from '@/object-record/record-table/record-table-body/components/RecordTableBodyFocusClickOutsideEffect';
import { RecordTableBodyFocusKeyboardEffect } from '@/object-record/record-table/record-table-body/components/RecordTableBodyFocusKeyboardEffect';
import { RecordTableRecordGroupBodyEffects } from '@/object-record/record-table/record-table-body/components/RecordTableRecordGroupBodyEffects';
import { RecordTableNoRecordGroupScrollToPreviousRecordEffect } from '@/object-record/record-table/virtualization/components/RecordTableNoRecordGroupScrollToPreviousRecordEffect';
import { RecordTableVirtualizedInitialDataLoadEffect } from '@/object-record/record-table/virtualization/components/RecordTableVirtualizedInitialDataLoadEffect';
import { RecordTableVirtualizedFieldMetadataUpdateEffect } from '@/object-record/record-table/virtualization/components/RecordTableVirtualizedFieldMetadataUpdateEffect';
import { RecordTableVirtualizedInitialDataLoadEffect } from '@/object-record/record-table/virtualization/components/RecordTableVirtualizedInitialDataLoadEffect';
export interface RecordTableBodyEffectsWrapperProps {
hasRecordGroups: boolean;
@@ -22,6 +23,7 @@ export const RecordTableBodyEffectsWrapper = ({
<RecordTableRecordGroupBodyEffects />
) : (
<>
<RecordTableEmptyHasNewRecordEffect />
<RecordTableNoRecordGroupScrollToPreviousRecordEffect />
<RecordTableVirtualizedInitialDataLoadEffect />
<RecordTableVirtualizedFieldMetadataUpdateEffect />
@@ -0,0 +1,103 @@
import { useListenToObjectRecordOperationBrowserEvent } from '@/browser-event/hooks/useListenToObjectRecordOperationBrowserEvent';
import { type ObjectRecordOperationBrowserEventDetail } from '@/browser-event/types/ObjectRecordOperationBrowserEventDetail';
import { turnSortsIntoOrderBy } from '@/object-record/object-sort-dropdown/utils/turnSortsIntoOrderBy';
import { currentRecordFilterGroupsComponentState } from '@/object-record/record-filter-group/states/currentRecordFilterGroupsComponentState';
import { useFilterValueDependencies } from '@/object-record/record-filter/hooks/useFilterValueDependencies';
import { currentRecordFiltersComponentState } from '@/object-record/record-filter/states/currentRecordFiltersComponentState';
import { useRecordIndexContextOrThrow } from '@/object-record/record-index/contexts/RecordIndexContext';
import { recordIndexHasRecordsComponentSelector } from '@/object-record/record-index/states/selectors/recordIndexHasRecordsComponentSelector';
import { currentRecordSortsComponentState } from '@/object-record/record-sort/states/currentRecordSortsComponentState';
import { isRecordTableInitialLoadingComponentState } from '@/object-record/record-table/states/isRecordTableInitialLoadingComponentState';
import { recordTableWentFromEmptyToNotEmptyComponentState } from '@/object-record/record-table/states/recordTableWentFromEmptyToNotEmptyComponentState';
import { useListenToEventsForQuery } from '@/sse-db-event/hooks/useListenToEventsForQuery';
import { useAtomComponentSelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentSelectorValue';
import { useAtomComponentStateCallbackState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateCallbackState';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { useStore } from 'jotai';
import { useMemo } from 'react';
import { computeRecordGqlOperationFilter } from 'twenty-shared/utils';
export const RecordTableEmptyHasNewRecordEffect = () => {
const { objectMetadataItem } = useRecordIndexContextOrThrow();
const store = useStore();
const isRecordTableInitialLoading = useAtomComponentStateValue(
isRecordTableInitialLoadingComponentState,
);
const recordTableHasRecords = useAtomComponentSelectorValue(
recordIndexHasRecordsComponentSelector,
);
const recordTableWentFromEmptyToNotEmptyCallbackState =
useAtomComponentStateCallbackState(
recordTableWentFromEmptyToNotEmptyComponentState,
);
const { filterValueDependencies } = useFilterValueDependencies();
const currentRecordFilters = useAtomComponentStateValue(
currentRecordFiltersComponentState,
);
const currentRecordSorts = useAtomComponentStateValue(
currentRecordSortsComponentState,
);
const currentRecordFilterGroups = useAtomComponentStateValue(
currentRecordFilterGroupsComponentState,
);
const queryId = `record-table-empty-${objectMetadataItem.nameSingular}`;
const operationSignature = useMemo(
() => ({
objectNameSingular: objectMetadataItem.nameSingular,
variables: {
filter: computeRecordGqlOperationFilter({
fields: objectMetadataItem.fields,
recordFilters: currentRecordFilters,
recordFilterGroups: currentRecordFilterGroups,
filterValueDependencies,
}),
orderBy: turnSortsIntoOrderBy(objectMetadataItem, currentRecordSorts),
},
}),
[
objectMetadataItem,
currentRecordFilters,
currentRecordFilterGroups,
filterValueDependencies,
currentRecordSorts,
],
);
useListenToEventsForQuery({
queryId,
operationSignature,
});
const handleObjectRecordOperation = (
objectRecordOperationEventDetail: ObjectRecordOperationBrowserEventDetail,
) => {
const objectRecordOperation = objectRecordOperationEventDetail.operation;
if (
objectRecordOperation.type.includes('update') ||
objectRecordOperation.type.includes('create')
) {
if (!isRecordTableInitialLoading && !recordTableHasRecords) {
store.set(recordTableWentFromEmptyToNotEmptyCallbackState, true);
}
}
};
useListenToObjectRecordOperationBrowserEvent({
onObjectRecordOperationBrowserEvent: handleObjectRecordOperation,
objectMetadataItemId: objectMetadataItem.id,
});
return null;
};
@@ -0,0 +1,9 @@
import { RecordTableComponentInstanceContext } from '@/object-record/record-table/states/context/RecordTableComponentInstanceContext';
import { createAtomComponentState } from '@/ui/utilities/state/jotai/utils/createAtomComponentState';
export const recordTableWentFromEmptyToNotEmptyComponentState =
createAtomComponentState<boolean>({
key: 'recordTableWentFromEmptyToNotEmptyComponentState',
defaultValue: false,
componentInstanceContext: RecordTableComponentInstanceContext,
});
@@ -2,15 +2,16 @@ import { useRecordIndexTableFetchMore } from '@/object-record/record-index/hooks
import { useRecordTableContextOrThrow } from '@/object-record/record-table/contexts/RecordTableContext';
import { visibleRecordFieldsComponentSelector } from '@/object-record/record-field/states/visibleRecordFieldsComponentSelector';
import { recordTableWentFromEmptyToNotEmptyComponentState } from '@/object-record/record-table/states/recordTableWentFromEmptyToNotEmptyComponentState';
import { useTriggerInitialRecordTableDataLoad } from '@/object-record/record-table/virtualization/hooks/useTriggerInitialRecordTableDataLoad';
import { isInitializingVirtualTableDataLoadingComponentState } from '@/object-record/record-table/virtualization/states/isInitializingVirtualTableDataLoadingComponentState';
import { lastContextStoreVirtualizedViewIdComponentState } from '@/object-record/record-table/virtualization/states/lastContextStoreVirtualizedViewIdComponentState';
import { lastContextStoreVirtualizedVisibleRecordFieldsComponentState } from '@/object-record/record-table/virtualization/states/lastContextStoreVirtualizedVisibleRecordFieldsComponentState';
import { lastRecordTableQueryIdentifierComponentState } from '@/object-record/record-table/virtualization/states/lastRecordTableQueryIdentifierComponentState';
import { isFetchingMoreRecordsFamilyState } from '@/object-record/states/isFetchingMoreRecordsFamilyState';
import { useAtomFamilyStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomFamilyStateValue';
import { useAtomComponentSelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentSelectorValue';
import { useAtomComponentState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentState';
import { useAtomFamilyStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomFamilyStateValue';
import { useGetCurrentViewOnly } from '@/views/hooks/useGetCurrentViewOnly';
import isEmpty from 'lodash.isempty';
import { useEffect } from 'react';
@@ -31,6 +32,11 @@ export const RecordTableVirtualizedInitialDataLoadEffect = () => {
isInitializingVirtualTableDataLoadingComponentState,
);
const [
recordTableWentFromEmptyToNotEmpty,
setRecordTableWentFromEmptyToNotEmpty,
] = useAtomComponentState(recordTableWentFromEmptyToNotEmptyComponentState);
const isFetchingMoreRecords = useAtomFamilyStateValue(
isFetchingMoreRecordsFamilyState,
recordTableId,
@@ -78,6 +84,10 @@ export const RecordTableVirtualizedInitialDataLoadEffect = () => {
) {
setLastRecordTableQueryIdentifier(queryIdentifier);
await triggerInitialRecordTableDataLoad();
} else if (recordTableWentFromEmptyToNotEmpty) {
setRecordTableWentFromEmptyToNotEmpty(false);
await triggerInitialRecordTableDataLoad();
} else if (
JSON.stringify(lastContextStoreVirtualizedVisibleRecordFields) !==
@@ -98,6 +108,8 @@ export const RecordTableVirtualizedInitialDataLoadEffect = () => {
}
})();
}, [
recordTableWentFromEmptyToNotEmpty,
setRecordTableWentFromEmptyToNotEmpty,
queryIdentifier,
lastRecordTableQueryIdentifier,
triggerInitialRecordTableDataLoad,
@@ -0,0 +1,11 @@
import { PAGE_LAYOUT_FRAGMENT } from '@/dashboards/graphql/fragments/pageLayoutFragment';
import { gql } from '@apollo/client';
export const FIND_ALL_PAGE_LAYOUTS = gql`
${PAGE_LAYOUT_FRAGMENT}
query FindAllPageLayouts {
getPageLayouts {
...PageLayoutFragment
}
}
`;
@@ -0,0 +1,44 @@
import { useMetadataStore } from '@/metadata-store/hooks/useMetadataStore';
import { pageLayoutCurrentLayoutsComponentState } from '@/page-layout/states/pageLayoutCurrentLayoutsComponentState';
import { pageLayoutPersistedComponentState } from '@/page-layout/states/pageLayoutPersistedComponentState';
import { recordPageLayoutsState } from '@/page-layout/states/recordPageLayoutsState';
import { convertPageLayoutToTabLayouts } from '@/page-layout/utils/convertPageLayoutToTabLayouts';
import { transformPageLayout } from '@/page-layout/utils/transformPageLayout';
import { useStore } from 'jotai';
import { useCallback } from 'react';
import { type PageLayout as PageLayoutGenerated } from '~/generated-metadata/graphql';
export const useApplyPageLayouts = () => {
const store = useStore();
const { updateDraft, applyChanges } = useMetadataStore();
const applyPageLayouts = useCallback(
(pageLayouts: PageLayoutGenerated[]) => {
const transformedPageLayouts = pageLayouts.map(transformPageLayout);
for (const pageLayout of transformedPageLayouts) {
store.set(
pageLayoutPersistedComponentState.atomFamily({
instanceId: pageLayout.id,
}),
pageLayout,
);
store.set(
pageLayoutCurrentLayoutsComponentState.atomFamily({
instanceId: pageLayout.id,
}),
convertPageLayoutToTabLayouts(pageLayout),
);
}
store.set(recordPageLayoutsState.atom, transformedPageLayouts);
updateDraft('pageLayouts', transformedPageLayouts);
applyChanges();
},
[store, updateDraft, applyChanges],
);
return {
applyPageLayouts,
};
};
@@ -0,0 +1,26 @@
import { useApplyPageLayouts } from '@/page-layout/hooks/useApplyPageLayouts';
import { useCallback } from 'react';
import { isDefined } from 'twenty-shared/utils';
import { useFindAllPageLayoutsLazyQuery } from '~/generated-metadata/graphql';
export const useRefreshPageLayouts = () => {
const [findAllPageLayoutsLazy] = useFindAllPageLayoutsLazyQuery();
const { applyPageLayouts } = useApplyPageLayouts();
const refreshPageLayouts = useCallback(async () => {
const result = await findAllPageLayoutsLazy({
fetchPolicy: 'network-only',
});
if (!isDefined(result.data?.getPageLayouts)) {
return;
}
applyPageLayouts(result.data.getPageLayouts);
}, [findAllPageLayoutsLazy, applyPageLayouts]);
return {
refreshPageLayouts,
};
};
@@ -1,6 +1,9 @@
import { FieldMetadataSSEEffect } from '@/metadata-store/effect-components/FieldMetadataSSEEffect';
import { NavigationMenuItemSSEEffect } from '@/metadata-store/effect-components/NavigationMenuItemSSEEffect';
import { ObjectMetadataItemSSEEffect } from '@/metadata-store/effect-components/ObjectMetadataItemSSEEffect';
import { PageLayoutSSEEffect } from '@/metadata-store/effect-components/PageLayoutSSEEffect';
import { PageLayoutTabSSEEffect } from '@/metadata-store/effect-components/PageLayoutTabSSEEffect';
import { PageLayoutWidgetSSEEffect } from '@/metadata-store/effect-components/PageLayoutWidgetSSEEffect';
import { ViewFieldSSEEffect } from '@/metadata-store/effect-components/ViewFieldSSEEffect';
import { ViewSSEEffect } from '@/metadata-store/effect-components/ViewSSEEffect';
import { SSEClientEffect } from '@/sse-db-event/components/SSEClientEffect';
@@ -21,6 +24,9 @@ export const SSEProvider = ({ children }: SSEProviderProps) => {
<NavigationMenuItemSSEEffect />
<FieldMetadataSSEEffect />
<ObjectMetadataItemSSEEffect />
<PageLayoutSSEEffect />
<PageLayoutTabSSEEffect />
<PageLayoutWidgetSSEEffect />
<ViewFieldSSEEffect />
<ViewSSEEffect />
{children}
@@ -5,6 +5,7 @@ export enum ToolCategory {
METADATA = 'METADATA',
NATIVE_MODEL = 'NATIVE_MODEL',
VIEW = 'VIEW',
VIEW_FIELD = 'VIEW_FIELD',
DASHBOARD = 'DASHBOARD',
LOGIC_FUNCTION = 'LOGIC_FUNCTION',
}
@@ -19,10 +19,11 @@ import {
type ToolIndexEntry,
} from 'src/engine/core-modules/tool-provider/types/tool-descriptor.type';
import { CodeInterpreterTool } from 'src/engine/core-modules/tool/tools/code-interpreter-tool/code-interpreter-tool';
import { HttpTool } from 'src/engine/core-modules/tool/tools/http-tool/http-tool';
import { SearchHelpCenterTool } from 'src/engine/core-modules/tool/tools/search-help-center-tool/search-help-center-tool';
import { DraftEmailTool } from 'src/engine/core-modules/tool/tools/email-tool/draft-email-tool';
import { SendEmailTool } from 'src/engine/core-modules/tool/tools/email-tool/send-email-tool';
import { HttpTool } from 'src/engine/core-modules/tool/tools/http-tool/http-tool';
import { NavigateAppTool } from 'src/engine/core-modules/tool/tools/navigate-tool/navigate-app-tool';
import { SearchHelpCenterTool } from 'src/engine/core-modules/tool/tools/search-help-center-tool/search-help-center-tool';
import { type ToolInput } from 'src/engine/core-modules/tool/types/tool-input.type';
import { type Tool } from 'src/engine/core-modules/tool/types/tool.type';
import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service';
@@ -39,6 +40,7 @@ export class ActionToolProvider implements ToolProvider {
private readonly draftEmailTool: DraftEmailTool,
private readonly searchHelpCenterTool: SearchHelpCenterTool,
private readonly codeInterpreterTool: CodeInterpreterTool,
private readonly navigateAppTool: NavigateAppTool,
private readonly permissionsService: PermissionsService,
private readonly toolExecutorService: ToolExecutorService,
) {
@@ -48,6 +50,7 @@ export class ActionToolProvider implements ToolProvider {
['draft_email', this.draftEmailTool],
['search_help_center', this.searchHelpCenterTool],
['code_interpreter', this.codeInterpreterTool],
['navigate_app', this.navigateAppTool],
]);
// Register each action tool as a static handler in the executor
@@ -116,6 +119,14 @@ export class ActionToolProvider implements ToolProvider {
),
);
descriptors.push(
this.buildDescriptor(
'navigate_app',
this.navigateAppTool,
includeSchemas,
),
);
const hasCodeInterpreterPermission =
await this.permissionsService.hasToolPermission(
context.rolePermissionConfig,
@@ -0,0 +1,98 @@
import { Injectable, OnModuleInit } from '@nestjs/common';
import { PermissionFlagType } from 'twenty-shared/constants';
import {
type GenerateDescriptorOptions,
type ToolProvider,
type ToolProviderContext,
} from 'src/engine/core-modules/tool-provider/interfaces/tool-provider.interface';
import { ToolCategory } from 'src/engine/core-modules/tool-provider/enums/tool-category.enum';
import { ToolExecutorService } from 'src/engine/core-modules/tool-provider/services/tool-executor.service';
import {
type ToolDescriptor,
type ToolIndexEntry,
} from 'src/engine/core-modules/tool-provider/types/tool-descriptor.type';
import { toolSetToDescriptors } from 'src/engine/core-modules/tool-provider/utils/tool-set-to-descriptors.util';
import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service';
import { ViewFieldToolsFactory } from 'src/engine/metadata-modules/view-field/tools/view-field-tools.factory';
@Injectable()
export class ViewFieldToolProvider implements ToolProvider, OnModuleInit {
readonly category = ToolCategory.VIEW_FIELD;
constructor(
private readonly viewFieldToolsFactory: ViewFieldToolsFactory,
private readonly permissionsService: PermissionsService,
private readonly toolExecutorService: ToolExecutorService,
) {}
onModuleInit(): void {
const factory = this.viewFieldToolsFactory;
this.toolExecutorService.registerCategoryGenerator(
ToolCategory.VIEW_FIELD,
async (context) => {
const readTools = factory.generateReadTools(context.workspaceId);
const hasViewPermission =
await this.permissionsService.checkRolesPermissions(
context.rolePermissionConfig,
context.workspaceId,
PermissionFlagType.VIEWS,
);
if (hasViewPermission) {
const writeTools = factory.generateWriteTools(context.workspaceId);
return { ...readTools, ...writeTools };
}
return readTools;
},
);
}
async isAvailable(_context: ToolProviderContext): Promise<boolean> {
return true;
}
async generateDescriptors(
context: ToolProviderContext,
options?: GenerateDescriptorOptions,
): Promise<(ToolIndexEntry | ToolDescriptor)[]> {
const schemaOptions = {
includeSchemas: options?.includeSchemas ?? true,
};
const readTools = this.viewFieldToolsFactory.generateReadTools(
context.workspaceId,
);
const hasViewPermission =
await this.permissionsService.checkRolesPermissions(
context.rolePermissionConfig,
context.workspaceId,
PermissionFlagType.VIEWS,
);
if (hasViewPermission) {
const writeTools = this.viewFieldToolsFactory.generateWriteTools(
context.workspaceId,
);
return toolSetToDescriptors(
{ ...readTools, ...writeTools },
ToolCategory.VIEW_FIELD,
schemaOptions,
);
}
return toolSetToDescriptors(
readTools,
ToolCategory.VIEW_FIELD,
schemaOptions,
);
}
}
@@ -7,9 +7,10 @@ import { TOOL_PROVIDERS } from 'src/engine/core-modules/tool-provider/constants/
import { ActionToolProvider } from 'src/engine/core-modules/tool-provider/providers/action-tool.provider';
import { DashboardToolProvider } from 'src/engine/core-modules/tool-provider/providers/dashboard-tool.provider';
import { DatabaseToolProvider } from 'src/engine/core-modules/tool-provider/providers/database-tool.provider';
import { LogicFunctionToolProvider } from 'src/engine/core-modules/tool-provider/providers/logic-function-tool.provider';
import { MetadataToolProvider } from 'src/engine/core-modules/tool-provider/providers/metadata-tool.provider';
import { NativeModelToolProvider } from 'src/engine/core-modules/tool-provider/providers/native-model-tool.provider';
import { LogicFunctionToolProvider } from 'src/engine/core-modules/tool-provider/providers/logic-function-tool.provider';
import { ViewFieldToolProvider } from 'src/engine/core-modules/tool-provider/providers/view-field-tool.provider';
import { ViewToolProvider } from 'src/engine/core-modules/tool-provider/providers/view-tool.provider';
import { WorkflowToolProvider } from 'src/engine/core-modules/tool-provider/providers/workflow-tool.provider';
import { ToolExecutorService } from 'src/engine/core-modules/tool-provider/services/tool-executor.service';
@@ -19,10 +20,11 @@ import { AiAgentExecutionModule } from 'src/engine/metadata-modules/ai/ai-agent-
import { AiModelsModule } from 'src/engine/metadata-modules/ai/ai-models/ai-models.module';
import { FieldMetadataModule } from 'src/engine/metadata-modules/field-metadata/field-metadata.module';
import { WorkspaceManyOrAllFlatEntityMapsCacheModule } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.module';
import { LogicFunctionModule } from 'src/engine/metadata-modules/logic-function/logic-function.module';
import { ObjectMetadataModule } from 'src/engine/metadata-modules/object-metadata/object-metadata.module';
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
import { LogicFunctionModule } from 'src/engine/metadata-modules/logic-function/logic-function.module';
import { UserRoleModule } from 'src/engine/metadata-modules/user-role/user-role.module';
import { ViewFieldModule } from 'src/engine/metadata-modules/view-field/view-field.module';
import { ViewModule } from 'src/engine/metadata-modules/view/view.module';
import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache.module';
@@ -45,6 +47,7 @@ import { ToolRegistryService } from './services/tool-registry.service';
FieldMetadataModule,
PermissionsModule,
ViewModule,
ViewFieldModule,
WorkspaceCacheModule,
WorkspaceManyOrAllFlatEntityMapsCacheModule,
LogicFunctionModule,
@@ -60,6 +63,7 @@ import { ToolRegistryService } from './services/tool-registry.service';
MetadataToolProvider,
NativeModelToolProvider,
LogicFunctionToolProvider,
ViewFieldToolProvider,
ViewToolProvider,
WorkflowToolProvider,
{
@@ -72,6 +76,7 @@ import { ToolRegistryService } from './services/tool-registry.service';
databaseProvider: DatabaseToolProvider,
metadataProvider: MetadataToolProvider,
logicFunctionProvider: LogicFunctionToolProvider,
viewFieldProvider: ViewFieldToolProvider,
viewProvider: ViewToolProvider,
workflowProvider: WorkflowToolProvider,
) => [
@@ -80,6 +85,7 @@ import { ToolRegistryService } from './services/tool-registry.service';
databaseProvider,
metadataProvider,
logicFunctionProvider,
viewFieldProvider,
viewProvider,
workflowProvider,
],
@@ -89,6 +95,7 @@ import { ToolRegistryService } from './services/tool-registry.service';
DatabaseToolProvider,
MetadataToolProvider,
LogicFunctionToolProvider,
ViewFieldToolProvider,
ViewToolProvider,
WorkflowToolProvider,
],
@@ -1,16 +1,21 @@
import { z } from 'zod';
import { ToolCategory } from 'src/engine/core-modules/tool-provider/enums/tool-category.enum';
import { type ToolRegistryService } from 'src/engine/core-modules/tool-provider/services/tool-registry.service';
import { type ToolIndexEntry } from 'src/engine/core-modules/tool-provider/types/tool-descriptor.type';
export const GET_TOOL_CATALOG_TOOL_NAME = 'get_tool_catalog';
const availableCategories = Object.values(ToolCategory)
.map((entry) => entry.toString())
.join(', ');
export const getToolCatalogInputSchema = z.object({
categories: z
.array(z.string())
.optional()
.describe(
'Filter by category (e.g. DATABASE_CRUD, METADATA, VIEW, WORKFLOW, DASHBOARD, LOGIC_FUNCTION, ACTION). Omit to get all.',
`Filter by category. Available categories: ${availableCategories}. Omit to get all.`,
),
});
@@ -3,4 +3,5 @@ export enum ToolType {
SEND_EMAIL = 'SEND_EMAIL',
SEARCH_HELP_CENTER = 'SEARCH_HELP_CENTER',
CODE_INTERPRETER = 'CODE_INTERPRETER',
NAVIGATE_APP = 'NAVIGATE_APP',
}
@@ -12,7 +12,12 @@ import { DraftEmailTool } from 'src/engine/core-modules/tool/tools/email-tool/dr
import { EmailComposerService } from 'src/engine/core-modules/tool/tools/email-tool/email-composer.service';
import { SendEmailTool } from 'src/engine/core-modules/tool/tools/email-tool/send-email-tool';
import { HttpTool } from 'src/engine/core-modules/tool/tools/http-tool/http-tool';
import { NavigateAppTool } from 'src/engine/core-modules/tool/tools/navigate-tool/navigate-app-tool';
import { SearchHelpCenterTool } from 'src/engine/core-modules/tool/tools/search-help-center-tool/search-help-center-tool';
import { WorkspaceManyOrAllFlatEntityMapsCacheModule } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.module';
import { NavigationMenuItemModule } from 'src/engine/metadata-modules/navigation-menu-item/navigation-menu-item.module';
import { ObjectMetadataModule } from 'src/engine/metadata-modules/object-metadata/object-metadata.module';
import { ViewModule } from 'src/engine/metadata-modules/view/view.module';
import { MessagingImportManagerModule } from 'src/modules/messaging/message-import-manager/messaging-import-manager.module';
import { MessagingSendManagerModule } from 'src/modules/messaging/message-outbound-manager/messaging-send-manager.module';
@@ -26,6 +31,10 @@ import { MessagingSendManagerModule } from 'src/modules/messaging/message-outbou
FileModule,
JwtModule,
SecureHttpClientModule,
ObjectMetadataModule,
ViewModule,
NavigationMenuItemModule,
WorkspaceManyOrAllFlatEntityMapsCacheModule,
],
providers: [
HttpTool,
@@ -34,6 +43,7 @@ import { MessagingSendManagerModule } from 'src/modules/messaging/message-outbou
EmailComposerService,
SearchHelpCenterTool,
CodeInterpreterTool,
NavigateAppTool,
],
exports: [
HttpTool,
@@ -42,6 +52,7 @@ import { MessagingSendManagerModule } from 'src/modules/messaging/message-outbou
EmailComposerService,
SearchHelpCenterTool,
CodeInterpreterTool,
NavigateAppTool,
],
})
export class ToolModule {}
@@ -0,0 +1,62 @@
import { z } from 'zod';
export const NavigateAppInputZodSchema = z.discriminatedUnion('type', [
z.object({
type: z
.literal('navigateToView')
.describe(
'Navigate to a specific view by name. ONLY use this type when the user explicitly mentions the word "view" (e.g. "go to the My Companies view", "open view All People"). Do NOT use this for general navigation requests.',
),
viewName: z
.string()
.describe(
'The name of the view to navigate to (e.g. "My Companies", "All People")',
),
}),
z.object({
type: z
.literal('navigateToObject')
.describe(
'Navigate to the default view for an object. This is the PREFERRED and DEFAULT type for all navigation requests unless the user explicitly mentions the word "view".',
),
objectNameSingular: z
.string()
.describe(
'The singular name of the object to navigate to (e.g. "company", "person", "opportunity")',
),
}),
z.object({
type: z
.literal('navigateToRecord')
.describe(
'Navigate to a specific record page. Use this when the user wants to go to a particular record by name (e.g. "go to the company Acme", "open the person John Doe", "show me the deal Enterprise Plan").',
),
objectNameSingular: z
.string()
.describe(
'The singular name of the object type (e.g. "company", "person", "opportunity")',
),
recordName: z
.string()
.describe(
'The name or label of the record to navigate to (e.g. "Acme", "John Doe", "Enterprise Plan")',
),
}),
z.object({
type: z
.literal('wait')
.describe(
'Wait for a specified duration in milliseconds before continuing. Useful when you need the page to fully load after a navigation before taking further actions (e.g. 2000 for 2 seconds).',
),
durationMs: z
.number()
.int()
.min(0)
.max(30000)
.describe(
'The duration in milliseconds to wait (e.g. 2000 for 2 seconds). Maximum 30000 (30 seconds).',
),
}),
]);
export type NavigateAppInput = z.infer<typeof NavigateAppInputZodSchema>;
@@ -0,0 +1,357 @@
import { Injectable } from '@nestjs/common';
import { sleep } from 'cloudflare/core';
import Fuse from 'fuse.js';
import { NavigateAppToolOutput } from 'twenty-shared/ai';
import { FieldMetadataType, type ObjectRecord } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import {
type NavigateAppInput,
NavigateAppInputZodSchema,
} from 'src/engine/core-modules/tool/tools/navigate-tool/navigate-app-tool.schema';
import { type ToolInput } from 'src/engine/core-modules/tool/types/tool-input.type';
import { ToolOutput } from 'src/engine/core-modules/tool/types/tool-output.type';
import {
type Tool,
type ToolExecutionContext,
} from 'src/engine/core-modules/tool/types/tool.type';
import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util';
import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
import { NavigationMenuItemService } from 'src/engine/metadata-modules/navigation-menu-item/navigation-menu-item.service';
import { ViewService } from 'src/engine/metadata-modules/view/services/view.service';
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
import { buildSystemAuthContext } from 'src/engine/twenty-orm/utils/build-system-auth-context.util';
@Injectable()
export class NavigateAppTool implements Tool {
description = `Navigate the application.
Use navigateToRecord when the user wants to go to a specific record by name.
Default to navigateToObject for all other navigation requests.
Only use navigateToView when the user explicitly mentions the word "view" in their request.
If the user asks to wait, use the wait tool with the specified duration.`;
inputSchema = NavigateAppInputZodSchema;
constructor(
private readonly navigationMenuItemService: NavigationMenuItemService,
private readonly viewService: ViewService,
private readonly workspaceManyOrAllFlatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService,
private readonly globalWorkspaceOrmManager: GlobalWorkspaceOrmManager,
) {}
async execute(
parameters: ToolInput,
context: ToolExecutionContext,
): Promise<ToolOutput> {
const parseResult = NavigateAppInputZodSchema.safeParse(parameters);
if (!parseResult.success) {
return {
success: false,
message: 'Invalid navigation input',
error: parseResult.error.message,
};
}
const input: NavigateAppInput = parseResult.data;
switch (input.type) {
case 'navigateToView':
return this.navigateToView(
input.viewName,
context.workspaceId,
context.userWorkspaceId,
);
case 'navigateToObject':
return this.navigateToObject(
input.objectNameSingular,
context.workspaceId,
);
case 'navigateToRecord':
return this.navigateToRecord(
input.objectNameSingular,
input.recordName,
context.workspaceId,
);
case 'wait':
return this.wait(input.durationMs);
}
}
private async wait(
durationMs: number,
): Promise<ToolOutput<NavigateAppToolOutput>> {
await sleep(durationMs);
return {
success: true,
message: `Waited for ${durationMs}ms`,
result: {
action: 'wait',
durationMs,
},
};
}
private async navigateToView(
viewName: string,
workspaceId: string,
userWorkspaceId?: string,
): Promise<ToolOutput<NavigateAppToolOutput>> {
const views = await this.viewService.findByWorkspaceId(
workspaceId,
userWorkspaceId,
);
const fuse = new Fuse(views, {
keys: ['name'],
threshold: 0.4,
});
const results = fuse.search(viewName);
const matchingView = results[0]?.item;
if (!matchingView) {
const availableViewNames = views.map((view) => view.name).join(', ');
return {
success: false,
message: `View "${viewName}" not found`,
error: `No view matching "${viewName}" was found in this workspace. Available views: ${availableViewNames}`,
};
}
return {
success: true,
message: `Navigating to view "${matchingView.name}"`,
result: {
action: 'navigateToView',
viewName: matchingView.name,
},
};
}
private async navigateToObject(
objectNameSingular: string,
workspaceId: string,
): Promise<ToolOutput<NavigateAppToolOutput>> {
const navigationMenuItems = await this.navigationMenuItemService.findAll({
workspaceId,
});
const { flatObjectMetadataMaps, flatViewMaps } =
await this.workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
workspaceId,
flatMapsKeys: [
'flatObjectMetadataMaps',
'flatViewMaps',
'flatNavigationMenuItemMaps',
],
},
);
const availableObjectNames = navigationMenuItems
.map((navigationMenuItem) => {
if (isDefined(navigationMenuItem.viewId)) {
const correspondingViewUniversalIdentifier =
flatViewMaps.universalIdentifierById[navigationMenuItem.viewId];
if (isDefined(correspondingViewUniversalIdentifier)) {
const correspondingView =
flatViewMaps.byUniversalIdentifier[
correspondingViewUniversalIdentifier
];
if (isDefined(correspondingView)) {
const correspondingObjectMetadataUniversalIdentifier =
flatObjectMetadataMaps.universalIdentifierById[
correspondingView.objectMetadataId
];
if (isDefined(correspondingObjectMetadataUniversalIdentifier)) {
const correspondingObjectMetadata =
flatObjectMetadataMaps.byUniversalIdentifier[
correspondingObjectMetadataUniversalIdentifier
];
if (isDefined(correspondingObjectMetadata)) {
const correspondingObjectNameSingular =
correspondingObjectMetadata.nameSingular;
return correspondingObjectNameSingular;
}
}
}
}
}
return null;
})
.filter(isDefined);
const fuse = new Fuse(availableObjectNames, {
threshold: 0.6,
});
const results = fuse.search(objectNameSingular.replace(/\s/g, ''));
const firstMatchingNavigationItemLabel = results[0]?.item;
if (!isDefined(firstMatchingNavigationItemLabel)) {
return {
success: false,
message: `Object "${objectNameSingular}" not found`,
error: `No object with singular name "${objectNameSingular}" was found in this workspace. Available objects: ${availableObjectNames}`,
};
}
return {
success: true,
message: `Navigating to ${firstMatchingNavigationItemLabel} default view`,
result: {
action: 'navigateToObject',
objectNameSingular: firstMatchingNavigationItemLabel,
},
};
}
private async navigateToRecord(
objectNameSingular: string,
recordName: string,
workspaceId: string,
): Promise<ToolOutput<NavigateAppToolOutput>> {
const { flatObjectMetadataMaps, flatFieldMetadataMaps } =
await this.workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
workspaceId,
flatMapsKeys: ['flatObjectMetadataMaps', 'flatFieldMetadataMaps'],
},
);
const flatObjectMetadata = Object.values(
flatObjectMetadataMaps.byUniversalIdentifier,
).find(
(metadata): metadata is FlatObjectMetadata =>
isDefined(metadata) &&
metadata.nameSingular === objectNameSingular &&
metadata.isActive,
);
if (!isDefined(flatObjectMetadata)) {
const availableObjectNames = Object.values(
flatObjectMetadataMaps.byUniversalIdentifier,
)
.filter(
(metadata): metadata is FlatObjectMetadata =>
isDefined(metadata) && metadata.isActive,
)
.map((metadata) => metadata.nameSingular)
.join(', ');
return {
success: false,
message: `Object "${objectNameSingular}" not found`,
error: `No object with singular name "${objectNameSingular}" was found. Available objects: ${availableObjectNames}`,
};
}
if (!isDefined(flatObjectMetadata.labelIdentifierFieldMetadataId)) {
return {
success: false,
message: `Object "${objectNameSingular}" has no label identifier field`,
error: `Cannot search records by name for object "${objectNameSingular}" because it has no label identifier field configured.`,
};
}
const labelIdentifierField = findFlatEntityByIdInFlatEntityMaps({
flatEntityId: flatObjectMetadata.labelIdentifierFieldMetadataId,
flatEntityMaps: flatFieldMetadataMaps,
});
if (!isDefined(labelIdentifierField)) {
return {
success: false,
message: `Label identifier field not found for object "${objectNameSingular}"`,
error: `The label identifier field metadata could not be resolved for object "${objectNameSingular}".`,
};
}
const isFullName =
labelIdentifierField.type === FieldMetadataType.FULL_NAME;
const selectColumns = isFullName
? [
'id',
`${labelIdentifierField.name}FirstName`,
`${labelIdentifierField.name}LastName`,
]
: ['id', labelIdentifierField.name];
const authContext = buildSystemAuthContext(workspaceId);
const records =
await this.globalWorkspaceOrmManager.executeInWorkspaceContext(
async () => {
const repository =
await this.globalWorkspaceOrmManager.getRepository<ObjectRecord>(
workspaceId,
objectNameSingular,
{ shouldBypassPermissionChecks: true },
);
return repository.find({
select: selectColumns,
});
},
authContext,
);
const recordsWithDisplayName = records.map((record) => {
let displayName: string;
if (isFullName) {
const firstName =
(record[`${labelIdentifierField.name}FirstName`] as string) ?? '';
const lastName =
(record[`${labelIdentifierField.name}LastName`] as string) ?? '';
displayName = `${firstName} ${lastName}`.trim();
} else {
displayName = String(record[labelIdentifierField.name] ?? '');
}
return {
id: record.id as string,
displayName,
};
});
const fuse = new Fuse(recordsWithDisplayName, {
keys: ['displayName'],
threshold: 0.4,
});
const results = fuse.search(recordName);
const matchingRecord = results[0]?.item;
if (!isDefined(matchingRecord)) {
return {
success: false,
message: `Record "${recordName}" not found in ${objectNameSingular}`,
error: `No ${objectNameSingular} record matching "${recordName}" was found.`,
};
}
return {
success: true,
message: `Navigating to ${objectNameSingular} record "${matchingRecord.displayName}"`,
result: {
action: 'navigateToRecord',
objectNameSingular,
recordId: matchingRecord.id,
},
};
}
}
@@ -1,5 +1,7 @@
import { Injectable } from '@nestjs/common';
import { assertUnreachable } from 'twenty-shared/utils';
import { COMMON_PRELOAD_TOOLS } from 'src/engine/core-modules/tool-provider/constants/common-preload-tools.const';
import { ToolCategory } from 'src/engine/core-modules/tool-provider/enums/tool-category.enum';
import { ToolRegistryService } from 'src/engine/core-modules/tool-provider/services/tool-registry.service';
@@ -279,15 +281,7 @@ ${otherPreloadedTools.length > 0 ? otherPreloadedTools.map((toolName) => `- \`${
### Tool Catalog by Category`);
const categoryOrder = [
ToolCategory.DATABASE_CRUD,
ToolCategory.ACTION,
ToolCategory.WORKFLOW,
ToolCategory.DASHBOARD,
ToolCategory.METADATA,
ToolCategory.VIEW,
ToolCategory.LOGIC_FUNCTION,
];
const categoryOrder = Object.values(ToolCategory);
for (const category of categoryOrder) {
const tools = toolsByCategory.get(category);
@@ -321,7 +315,7 @@ ${hasWebSearch ? '3' : '2'}. **Other tools**: First call \`${LEARN_TOOLS_TOOL_NA
return sections.join('\n');
}
private getCategoryLabel(category: string): string {
private getCategoryLabel(category: ToolCategory): string {
switch (category) {
case ToolCategory.DATABASE_CRUD:
return 'Database Tools (CRUD operations)';
@@ -337,8 +331,12 @@ ${hasWebSearch ? '3' : '2'}. **Other tools**: First call \`${LEARN_TOOLS_TOOL_NA
return 'Dashboard Tools (create/manage dashboards)';
case ToolCategory.LOGIC_FUNCTION:
return 'Logic Functions (custom tools)';
case ToolCategory.NATIVE_MODEL:
return 'Native Model Capabilities (e.g. web search)';
case ToolCategory.VIEW_FIELD:
return 'View Field Tools (manage view columns)';
default:
return category;
return assertUnreachable(category);
}
}
}
@@ -104,6 +104,22 @@ const DeleteFieldMetadataInputSchema = z.object({
id: z.string().uuid().describe('ID of the field to delete'),
});
const CreateManyFieldMetadataInputSchema = z.object({
fields: z
.array(CreateFieldMetadataInputSchema)
.min(1)
.max(20)
.describe('Array of field metadata to create (1-20 items).'),
});
const UpdateManyFieldMetadataInputSchema = z.object({
fields: z
.array(UpdateFieldMetadataInputSchema)
.min(1)
.max(20)
.describe('Array of field metadata updates to apply (1-20 items).'),
});
@Injectable()
export class FieldMetadataToolsFactory {
constructor(private readonly fieldMetadataService: FieldMetadataService) {}
@@ -229,6 +245,86 @@ export class FieldMetadataToolsFactory {
}
},
},
create_many_field_metadata: {
description:
'Create multiple field metadata at once on one or more objects. More efficient than calling create_field_metadata multiple times. Each item follows the same schema as create_field_metadata.',
inputSchema: CreateManyFieldMetadataInputSchema,
execute: async (parameters: {
fields: Array<{
objectMetadataId: string;
type: FieldMetadataType;
name: string;
label: string;
description?: string;
icon?: string;
isNullable?: boolean;
isUnique?: boolean;
defaultValue?: unknown;
options?: unknown;
settings?: unknown;
isLabelSyncedWithName?: boolean;
isRemoteCreation?: boolean;
relationCreationPayload?: unknown;
}>;
}) => {
try {
await this.fieldMetadataService.createManyFields({
createFieldInputs: parameters.fields as Parameters<
typeof this.fieldMetadataService.createManyFields
>[0]['createFieldInputs'],
workspaceId,
});
return true;
} catch (error) {
if (error instanceof WorkspaceMigrationBuilderException) {
throw new Error(formatValidationErrors(error));
}
throw error;
}
},
},
update_many_field_metadata: {
description:
'Update multiple field metadata at once. More efficient than calling update_field_metadata multiple times. Each item must include the field ID and the properties to update.',
inputSchema: UpdateManyFieldMetadataInputSchema,
execute: async (parameters: {
fields: Array<{
id: string;
name?: string;
label?: string;
description?: string;
icon?: string;
isActive?: boolean;
isNullable?: boolean;
isUnique?: boolean;
defaultValue?: unknown;
options?: unknown;
settings?: unknown;
isLabelSyncedWithName?: boolean;
}>;
}) => {
try {
await Promise.all(
parameters.fields.map(async ({ id, ...update }) => {
await this.fieldMetadataService.updateOneField({
updateFieldInput: { id, ...update } as Parameters<
typeof this.fieldMetadataService.updateOneField
>[0]['updateFieldInput'],
workspaceId,
});
}),
);
return true;
} catch (error) {
if (error instanceof WorkspaceMigrationBuilderException) {
throw new Error(formatValidationErrors(error));
}
throw error;
}
},
},
};
}
}
@@ -81,6 +81,22 @@ const DeleteObjectMetadataInputSchema = z.object({
id: z.string().uuid().describe('ID of the object to delete'),
});
const CreateManyObjectMetadataInputSchema = z.object({
objects: z
.array(CreateObjectMetadataInputSchema)
.min(1)
.max(20)
.describe('Array of object metadata to create (1-20 items).'),
});
const UpdateManyObjectMetadataInputSchema = z.object({
objects: z
.array(UpdateObjectMetadataInputSchema)
.min(1)
.max(20)
.describe('Array of object metadata updates to apply (1-20 items).'),
});
@Injectable()
export class ObjectMetadataToolsFactory {
constructor(private readonly objectMetadataService: ObjectMetadataService) {}
@@ -202,6 +218,83 @@ export class ObjectMetadataToolsFactory {
}
},
},
create_many_object_metadata: {
description:
'Create multiple object metadata at once in the workspace data model. More efficient than calling create_object_metadata multiple times. Each item follows the same schema as create_object_metadata.',
inputSchema: CreateManyObjectMetadataInputSchema,
execute: async (parameters: {
objects: Array<{
nameSingular: string;
namePlural: string;
labelSingular: string;
labelPlural: string;
description?: string;
icon?: string;
shortcut?: string;
isRemote?: boolean;
isLabelSyncedWithName?: boolean;
}>;
}) => {
try {
await Promise.all(
parameters.objects.map(async (createObjectInput) => {
await this.objectMetadataService.createOneObject({
createObjectInput: createObjectInput as Parameters<
typeof this.objectMetadataService.createOneObject
>[0]['createObjectInput'],
workspaceId,
});
}),
);
return true;
} catch (error) {
if (error instanceof WorkspaceMigrationBuilderException) {
throw new Error(formatValidationErrors(error));
}
throw error;
}
},
},
update_many_object_metadata: {
description:
'Update multiple object metadata at once. More efficient than calling update_object_metadata multiple times. Each item must include the object ID and the properties to update.',
inputSchema: UpdateManyObjectMetadataInputSchema,
execute: async (parameters: {
objects: Array<{
id: string;
labelSingular?: string;
labelPlural?: string;
nameSingular?: string;
namePlural?: string;
description?: string;
icon?: string;
shortcut?: string;
isActive?: boolean;
labelIdentifierFieldMetadataId?: string;
imageIdentifierFieldMetadataId?: string;
isLabelSyncedWithName?: boolean;
}>;
}) => {
try {
await Promise.all(
parameters.objects.map(async ({ id, ...update }) => {
await this.objectMetadataService.updateOneObject({
updateObjectInput: { id, update },
workspaceId,
});
}),
);
return true;
} catch (error) {
if (error instanceof WorkspaceMigrationBuilderException) {
throw new Error(formatValidationErrors(error));
}
throw error;
}
},
},
};
}
}
@@ -0,0 +1,357 @@
import { Injectable } from '@nestjs/common';
import { type ToolSet } from 'ai';
import { AggregateOperations } from 'twenty-shared/types';
import { z } from 'zod';
import { formatValidationErrors } from 'src/engine/core-modules/tool-provider/utils/format-validation-errors.util';
import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
import { ViewFieldService } from 'src/engine/metadata-modules/view-field/services/view-field.service';
import { WorkspaceMigrationBuilderException } from 'src/engine/workspace-manager/workspace-migration/exceptions/workspace-migration-builder-exception';
const GetViewFieldsInputSchema = z.object({
viewId: z
.string()
.uuid()
.describe(
'The ID of the view to list fields for. Obtain this from get_views.',
),
});
const CreateViewFieldInputSchema = z.object({
viewId: z.string().uuid().describe('The ID of the view to add the field to.'),
fieldMetadataId: z
.string()
.uuid()
.describe(
'The ID of the field metadata to add. Use get_field_metadata to find available fields.',
),
isVisible: z
.boolean()
.optional()
.default(true)
.describe('Whether the field is visible in the view.'),
size: z
.number()
.int()
.optional()
.default(150)
.describe('Column width in pixels.'),
position: z
.number()
.optional()
.default(0)
.describe('Position of the field in the view (0-based).'),
aggregateOperation: z
.enum(Object.values(AggregateOperations) as [string, ...string[]])
.optional()
.describe(
'Aggregate operation for this field (e.g., "SUM", "AVG", "COUNT").',
),
});
const UpdateViewFieldInputSchema = z.object({
id: z
.string()
.uuid()
.describe(
'The ID of the view field to update. Obtain this from get_view_fields.',
),
isVisible: z.boolean().optional().describe('Whether the field is visible.'),
size: z.number().int().optional().describe('Column width in pixels.'),
position: z.number().optional().describe('Position of the field.'),
aggregateOperation: z
.enum(Object.values(AggregateOperations) as [string, ...string[]])
.optional()
.describe('Aggregate operation for this field.'),
});
const DeleteViewFieldInputSchema = z.object({
id: z
.string()
.uuid()
.describe(
'The ID of the view field to delete. Obtain this from get_view_fields.',
),
});
const CreateManyViewFieldsInputSchema = z.object({
viewFields: z
.array(CreateViewFieldInputSchema)
.min(1)
.max(50)
.describe('Array of view fields to create (1-50 items).'),
});
const UpdateManyViewFieldsInputSchema = z.object({
viewFields: z
.array(UpdateViewFieldInputSchema)
.min(1)
.max(50)
.describe('Array of view field updates to apply (1-50 items).'),
});
@Injectable()
export class ViewFieldToolsFactory {
constructor(
private readonly viewFieldService: ViewFieldService,
private readonly flatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService,
) {}
private async resolveFieldName(
workspaceId: string,
fieldMetadataId: string,
): Promise<string | undefined> {
const { flatFieldMetadataMaps } =
await this.flatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
workspaceId,
flatMapsKeys: ['flatFieldMetadataMaps'],
},
);
const universalIdentifier =
flatFieldMetadataMaps.universalIdentifierById[fieldMetadataId];
if (!universalIdentifier) {
return undefined;
}
return flatFieldMetadataMaps.byUniversalIdentifier[universalIdentifier]
?.name;
}
generateReadTools(workspaceId: string): ToolSet {
return {
get_view_fields: {
description:
'List the columns (fields) displayed in a specific view. A view field controls which columns appear in a table or kanban view, their visibility, width, position, and aggregate operation. Use get_views first to find the view ID, then call this to inspect its column configuration.',
inputSchema: GetViewFieldsInputSchema,
execute: async (parameters: { viewId: string }) => {
const viewFields = await this.viewFieldService.findByViewId(
workspaceId,
parameters.viewId,
);
const viewFieldsWithNames = await Promise.all(
viewFields.map(async (viewField) => {
const fieldName = await this.resolveFieldName(
workspaceId,
viewField.fieldMetadataId,
);
return {
id: viewField.id,
fieldMetadataId: viewField.fieldMetadataId,
fieldName: fieldName ?? null,
viewId: viewField.viewId,
isVisible: viewField.isVisible,
size: viewField.size,
position: viewField.position,
aggregateOperation: viewField.aggregateOperation,
};
}),
);
return viewFieldsWithNames;
},
},
};
}
generateWriteTools(workspaceId: string): ToolSet {
return {
create_view_field: {
description:
'Add a new column to a view. View fields define which columns are shown in table or kanban views. First call get_field_metadata to find the fieldMetadataId of the column to add, and get_views to find the target viewId.',
inputSchema: CreateViewFieldInputSchema,
execute: async (parameters: {
viewId: string;
fieldMetadataId: string;
isVisible?: boolean;
size?: number;
position?: number;
aggregateOperation?: string;
}) => {
try {
const viewField = await this.viewFieldService.createOne({
createViewFieldInput: {
viewId: parameters.viewId,
fieldMetadataId: parameters.fieldMetadataId,
isVisible: parameters.isVisible ?? true,
size: parameters.size ?? 150,
position: parameters.position ?? 0,
aggregateOperation:
parameters.aggregateOperation as AggregateOperations,
},
workspaceId,
});
return {
id: viewField.id,
fieldMetadataId: viewField.fieldMetadataId,
viewId: viewField.viewId,
isVisible: viewField.isVisible,
size: viewField.size,
position: viewField.position,
aggregateOperation: viewField.aggregateOperation,
};
} catch (error) {
if (error instanceof WorkspaceMigrationBuilderException) {
throw new Error(formatValidationErrors(error));
}
throw error;
}
},
},
update_view_field: {
description:
"Update properties of a column in a view. You can change its visibility, width (size in pixels), display position, or aggregate operation. Use get_view_fields to find the view field ID. Constraints: position must not be -1, must not precede the label identifier field, and must not conflict with another field's position.",
inputSchema: UpdateViewFieldInputSchema,
execute: async (parameters: {
id: string;
isVisible?: boolean;
size?: number;
position?: number;
aggregateOperation?: string;
}) => {
try {
const viewField = await this.viewFieldService.updateOne({
updateViewFieldInput: {
id: parameters.id,
update: {
isVisible: parameters.isVisible,
size: parameters.size,
position: parameters.position,
aggregateOperation:
parameters.aggregateOperation as AggregateOperations,
},
},
workspaceId,
});
return {
id: viewField.id,
fieldMetadataId: viewField.fieldMetadataId,
viewId: viewField.viewId,
isVisible: viewField.isVisible,
size: viewField.size,
position: viewField.position,
aggregateOperation: viewField.aggregateOperation,
};
} catch (error) {
if (error instanceof WorkspaceMigrationBuilderException) {
throw new Error(formatValidationErrors(error));
}
throw error;
}
},
},
delete_view_field: {
description:
"Remove a column from a view. This removes the field from the view's displayed columns. Use get_view_fields to find the view field ID to delete.",
inputSchema: DeleteViewFieldInputSchema,
execute: async (parameters: { id: string }) => {
try {
const viewField = await this.viewFieldService.deleteOne({
deleteViewFieldInput: { id: parameters.id },
workspaceId,
});
return {
id: viewField.id,
deleted: true,
};
} catch (error) {
if (error instanceof WorkspaceMigrationBuilderException) {
throw new Error(formatValidationErrors(error));
}
throw error;
}
},
},
create_many_view_fields: {
description:
'Add multiple columns to a view at once. More efficient than calling create_view_field multiple times. Each item follows the same schema as create_view_field. All view fields can target the same or different views.',
inputSchema: CreateManyViewFieldsInputSchema,
execute: async (parameters: {
viewFields: Array<{
viewId: string;
fieldMetadataId: string;
isVisible?: boolean;
size?: number;
position?: number;
aggregateOperation?: string;
}>;
}) => {
try {
await this.viewFieldService.createMany({
createViewFieldInputs: parameters.viewFields.map((viewField) => ({
viewId: viewField.viewId,
fieldMetadataId: viewField.fieldMetadataId,
isVisible: viewField.isVisible ?? true,
size: viewField.size ?? 150,
position: viewField.position ?? 0,
aggregateOperation:
viewField.aggregateOperation as AggregateOperations,
})),
workspaceId,
});
return true;
} catch (error) {
if (error instanceof WorkspaceMigrationBuilderException) {
throw new Error(formatValidationErrors(error));
}
throw error;
}
},
},
update_many_view_fields: {
description:
'Update multiple columns in a view at once. More efficient than calling update_view_field multiple times. Each item must include the view field ID and properties to update. Same constraints as update_view_field apply to each item.',
inputSchema: UpdateManyViewFieldsInputSchema,
execute: async (parameters: {
viewFields: Array<{
id: string;
isVisible?: boolean;
size?: number;
position?: number;
aggregateOperation?: string;
}>;
}) => {
try {
await Promise.all(
parameters.viewFields.map(async (viewField) => {
await this.viewFieldService.updateOne({
updateViewFieldInput: {
id: viewField.id,
update: {
isVisible: viewField.isVisible,
size: viewField.size,
position: viewField.position,
aggregateOperation:
viewField.aggregateOperation as AggregateOperations,
},
},
workspaceId,
});
}),
);
return true;
} catch (error) {
if (error instanceof WorkspaceMigrationBuilderException) {
throw new Error(formatValidationErrors(error));
}
throw error;
}
},
},
};
}
}
@@ -8,6 +8,7 @@ import { ViewFieldController } from 'src/engine/metadata-modules/view-field/cont
import { ViewFieldEntity } from 'src/engine/metadata-modules/view-field/entities/view-field.entity';
import { ViewFieldResolver } from 'src/engine/metadata-modules/view-field/resolvers/view-field.resolver';
import { ViewFieldService } from 'src/engine/metadata-modules/view-field/services/view-field.service';
import { ViewFieldToolsFactory } from 'src/engine/metadata-modules/view-field/tools/view-field-tools.factory';
import { ViewPermissionsModule } from 'src/engine/metadata-modules/view-permissions/view-permissions.module';
import { ViewEntity } from 'src/engine/metadata-modules/view/entities/view.entity';
import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/workspace-cache-storage.module';
@@ -24,7 +25,7 @@ import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace
ViewPermissionsModule,
],
controllers: [ViewFieldController],
providers: [ViewFieldResolver, ViewFieldService],
exports: [ViewFieldService],
providers: [ViewFieldResolver, ViewFieldService, ViewFieldToolsFactory],
exports: [ViewFieldService, ViewFieldToolsFactory],
})
export class ViewFieldModule {}
@@ -366,6 +366,10 @@ export const seedAgents = async ({
schemaName,
workspaceId,
}: SeedAgentsArgs) => {
if (workspaceId === SEED_APPLE_WORKSPACE_ID) {
return;
}
const threadId = await seedChatThreads({
queryRunner,
schemaName,
@@ -29,6 +29,9 @@ export const STANDARD_SKILL = {
pptx: {
universalIdentifier: '20202020-13b5-4e60-9359-b8519ef1c07d',
},
'workspace-demo-seeding': {
universalIdentifier: '20202020-c81b-4af8-9255-4c34bd0eac9c',
},
} as const satisfies Record<
string,
{
@@ -138,6 +138,58 @@ Prioritize data integrity and provide clear feedback on operations performed.`,
},
}),
'workspace-demo-seeding': (args: Omit<CreateStandardSkillArgs, 'context'>) =>
createStandardSkillFlatMetadata({
...args,
context: {
skillName: 'workspace-demo-seeding',
name: 'workspace-demo-seeding',
label: 'Workspace Demo Seeding',
description:
'Seeding demo metadata and data for workspace setup and testing purposes',
icon: 'IconDatabase',
content: `# Workspace Demo Seeding Skill
You will create a demo workspace that fits a particular type of company given by the user.
Do not ask the user for more information, just be creative with the objects and fields, but stay professional and coherent.
Create relations fields between objects, for example a car repair shop workspace would have objects for cars, employees, repairs, customers, and the relevant relations between them.
DO NOT USE code-interpreter tool at all. Prefer more steps.
LIMIT TO 3 OBJECTS FOR DEMO, AND 3 FIELDS FOR EACH OBJECT, to avoid bugs.
For the fields you will create, make sure to create a good variety of field types to showcase the different capabilities of the platform, for example:
- Create SELECT and SELECT_MULTIPLE field types for building demo board index views and table with groups views
- Create DATE_TIME fields to be able to create calendar views
- Create CURRENCY and NUMERIC fields for graphs
Here are the steps for you to work properly :
- Proceed object by object, for each object.
- Create the object with the right tool, DO THIS FIRST
- Wait 3 seconds before navigating, for the view to be populated by the backend
- Navigate to its default view
- Then create each relevant field metadata one by one, and create a view field for each of them, reorder them to the start so we see them.
- Then seed mock data relevant :
- use the tool that is related to the object, look for tools, create_my_new_object, create_many_of_my_new_object, look again in tools, don't use http
- between 20 and 50
- with a coherent combination of values
- proceed with the relevant tools for each object, do not use code-interpreter
- navigate to each default view before seeding an object, so the user can see what happens.
After you've finished with this part, let's proceed to the dashboard creation. We will create a dashboard with 4 graphs.
- Navigate to the dashboard list default view
- Create a new dashboard
- Navigate to the dashboard page
- Create 4 graphs :
- For each graph, find a relevant amount for y axis, a relevant date or select field for x axis, and if necessary a relevant group by stack
- Change the name of each graph so it is relevant
- Turn on the labels on the graphs
`,
isCustom: false,
},
}),
'dashboard-building': (args: Omit<CreateStandardSkillArgs, 'context'>) =>
createStandardSkillFlatMetadata({
...args,
+1
View File
@@ -26,3 +26,4 @@ export type {
} from './types/ExtendedUIMessage';
export type { ExtendedUIMessagePart } from './types/ExtendedUIMessagePart';
export type { ModelConfiguration } from './types/model-configuration.type';
export type { NavigateAppToolOutput } from './types/NavigateAppToolOutput';
@@ -1,4 +1,5 @@
import { type DataMessagePart } from '@/ai/types/DataMessagePart';
import { type Nullable } from '@/types';
import { type UIMessage } from 'ai';
export type AIChatUsageMetadata = {
@@ -20,4 +21,6 @@ type Metadata = {
model?: AIChatModelMetadata;
};
export type ExtendedUIMessage = UIMessage<Metadata, DataMessagePart>;
export type ExtendedUIMessage = UIMessage<Metadata, DataMessagePart> & {
threadId?: Nullable<string>;
};
@@ -0,0 +1,18 @@
export type NavigateAppToolOutput =
| {
action: 'navigateToObject';
objectNameSingular: string;
}
| {
action: 'navigateToView';
viewName: string;
}
| {
action: 'navigateToRecord';
objectNameSingular: string;
recordId: string;
}
| {
action: 'wait';
durationMs: number;
};
@@ -225,6 +225,7 @@ export {
IconInbox,
IconInfoCircle,
IconItalic,
IconJetpack,
IconJson,
IconKey,
IconLanguage,
+1
View File
@@ -300,6 +300,7 @@ export {
IconInbox,
IconInfoCircle,
IconItalic,
IconJetpack,
IconJson,
IconKey,
IconLanguage,