feat(ai): add current context to ai chat (#13315)

## TODO

- [ ] add dropdown to use records from outside the context
- [x] add loader for files chip
- [x] add roleId where it's necessary
- [x] Split AvatarChip in two components. One with the icon that will
call the second with leftComponent.
- [ ] Fix tests
- [x] Fix UI regression on Search
This commit is contained in:
Antoine Moreaux
2025-07-22 17:27:19 +02:00
committed by GitHub
parent d46a076aa0
commit 153739b9c3
69 changed files with 1111 additions and 819 deletions

View File

@ -2971,6 +2971,14 @@ export type WorkspaceUrlsAndId = {
workspaceUrls: WorkspaceUrls;
};
export type AssignRoleToAgentMutationVariables = Exact<{
agentId: Scalars['UUID'];
roleId: Scalars['UUID'];
}>;
export type AssignRoleToAgentMutation = { __typename?: 'Mutation', assignRoleToAgent: boolean };
export type CreateAgentChatThreadMutationVariables = Exact<{
input: CreateAgentChatThreadInput;
}>;
@ -2978,6 +2986,27 @@ export type CreateAgentChatThreadMutationVariables = Exact<{
export type CreateAgentChatThreadMutation = { __typename?: 'Mutation', createAgentChatThread: { __typename?: 'AgentChatThread', id: any, agentId: any, title?: string | null, createdAt: string, updatedAt: string } };
export type RemoveRoleFromAgentMutationVariables = Exact<{
agentId: Scalars['UUID'];
}>;
export type RemoveRoleFromAgentMutation = { __typename?: 'Mutation', removeRoleFromAgent: boolean };
export type UpdateOneAgentMutationVariables = Exact<{
input: UpdateAgentInput;
}>;
export type UpdateOneAgentMutation = { __typename?: 'Mutation', updateOneAgent: { __typename?: 'Agent', id: any, name: string, description?: string | null, prompt: string, modelId: string, responseFormat?: any | null } };
export type FindOneAgentQueryVariables = Exact<{
id: Scalars['UUID'];
}>;
export type FindOneAgentQuery = { __typename?: 'Query', findOneAgent: { __typename?: 'Agent', id: any, name: string, description?: string | null, prompt: string, modelId: string, responseFormat?: any | null, roleId?: any | null } };
export type GetAgentChatMessagesQueryVariables = Exact<{
threadId: Scalars['String'];
}>;
@ -3795,35 +3824,6 @@ export type UpdateWorkflowVersionStepMutationVariables = Exact<{
export type UpdateWorkflowVersionStepMutation = { __typename?: 'Mutation', updateWorkflowVersionStep: { __typename?: 'WorkflowAction', id: any, name: string, type: string, settings: any, valid: boolean, nextStepIds?: Array<any> | null } };
export type AssignRoleToAgentMutationVariables = Exact<{
agentId: Scalars['UUID'];
roleId: Scalars['UUID'];
}>;
export type AssignRoleToAgentMutation = { __typename?: 'Mutation', assignRoleToAgent: boolean };
export type RemoveRoleFromAgentMutationVariables = Exact<{
agentId: Scalars['UUID'];
}>;
export type RemoveRoleFromAgentMutation = { __typename?: 'Mutation', removeRoleFromAgent: boolean };
export type UpdateOneAgentMutationVariables = Exact<{
input: UpdateAgentInput;
}>;
export type UpdateOneAgentMutation = { __typename?: 'Mutation', updateOneAgent: { __typename?: 'Agent', id: any, name: string, description?: string | null, prompt: string, modelId: string, responseFormat?: any | null } };
export type FindOneAgentQueryVariables = Exact<{
id: Scalars['UUID'];
}>;
export type FindOneAgentQuery = { __typename?: 'Query', findOneAgent: { __typename?: 'Agent', id: any, name: string, description?: string | null, prompt: string, modelId: string, responseFormat?: any | null, roleId?: any | null } };
export type SubmitFormStepMutationVariables = Exact<{
input: SubmitFormStepInput;
}>;
@ -4169,6 +4169,38 @@ ${ObjectPermissionFragmentFragmentDoc}
${WorkspaceUrlsFragmentFragmentDoc}
${RoleFragmentFragmentDoc}
${AvailableWorkspacesFragmentFragmentDoc}`;
export const AssignRoleToAgentDocument = gql`
mutation AssignRoleToAgent($agentId: UUID!, $roleId: UUID!) {
assignRoleToAgent(agentId: $agentId, roleId: $roleId)
}
`;
export type AssignRoleToAgentMutationFn = Apollo.MutationFunction<AssignRoleToAgentMutation, AssignRoleToAgentMutationVariables>;
/**
* __useAssignRoleToAgentMutation__
*
* To run a mutation, you first call `useAssignRoleToAgentMutation` within a React component and pass it any options that fit your needs.
* When your component renders, `useAssignRoleToAgentMutation` returns a tuple that includes:
* - A mutate function that you can call at any time to execute the mutation
* - An object with fields that represent the current status of the mutation's execution
*
* @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2;
*
* @example
* const [assignRoleToAgentMutation, { data, loading, error }] = useAssignRoleToAgentMutation({
* variables: {
* agentId: // value for 'agentId'
* roleId: // value for 'roleId'
* },
* });
*/
export function useAssignRoleToAgentMutation(baseOptions?: Apollo.MutationHookOptions<AssignRoleToAgentMutation, AssignRoleToAgentMutationVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useMutation<AssignRoleToAgentMutation, AssignRoleToAgentMutationVariables>(AssignRoleToAgentDocument, options);
}
export type AssignRoleToAgentMutationHookResult = ReturnType<typeof useAssignRoleToAgentMutation>;
export type AssignRoleToAgentMutationResult = Apollo.MutationResult<AssignRoleToAgentMutation>;
export type AssignRoleToAgentMutationOptions = Apollo.BaseMutationOptions<AssignRoleToAgentMutation, AssignRoleToAgentMutationVariables>;
export const CreateAgentChatThreadDocument = gql`
mutation CreateAgentChatThread($input: CreateAgentChatThreadInput!) {
createAgentChatThread(input: $input) {
@ -4206,6 +4238,116 @@ export function useCreateAgentChatThreadMutation(baseOptions?: Apollo.MutationHo
export type CreateAgentChatThreadMutationHookResult = ReturnType<typeof useCreateAgentChatThreadMutation>;
export type CreateAgentChatThreadMutationResult = Apollo.MutationResult<CreateAgentChatThreadMutation>;
export type CreateAgentChatThreadMutationOptions = Apollo.BaseMutationOptions<CreateAgentChatThreadMutation, CreateAgentChatThreadMutationVariables>;
export const RemoveRoleFromAgentDocument = gql`
mutation RemoveRoleFromAgent($agentId: UUID!) {
removeRoleFromAgent(agentId: $agentId)
}
`;
export type RemoveRoleFromAgentMutationFn = Apollo.MutationFunction<RemoveRoleFromAgentMutation, RemoveRoleFromAgentMutationVariables>;
/**
* __useRemoveRoleFromAgentMutation__
*
* To run a mutation, you first call `useRemoveRoleFromAgentMutation` within a React component and pass it any options that fit your needs.
* When your component renders, `useRemoveRoleFromAgentMutation` returns a tuple that includes:
* - A mutate function that you can call at any time to execute the mutation
* - An object with fields that represent the current status of the mutation's execution
*
* @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2;
*
* @example
* const [removeRoleFromAgentMutation, { data, loading, error }] = useRemoveRoleFromAgentMutation({
* variables: {
* agentId: // value for 'agentId'
* },
* });
*/
export function useRemoveRoleFromAgentMutation(baseOptions?: Apollo.MutationHookOptions<RemoveRoleFromAgentMutation, RemoveRoleFromAgentMutationVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useMutation<RemoveRoleFromAgentMutation, RemoveRoleFromAgentMutationVariables>(RemoveRoleFromAgentDocument, options);
}
export type RemoveRoleFromAgentMutationHookResult = ReturnType<typeof useRemoveRoleFromAgentMutation>;
export type RemoveRoleFromAgentMutationResult = Apollo.MutationResult<RemoveRoleFromAgentMutation>;
export type RemoveRoleFromAgentMutationOptions = Apollo.BaseMutationOptions<RemoveRoleFromAgentMutation, RemoveRoleFromAgentMutationVariables>;
export const UpdateOneAgentDocument = gql`
mutation UpdateOneAgent($input: UpdateAgentInput!) {
updateOneAgent(input: $input) {
id
name
description
prompt
modelId
responseFormat
}
}
`;
export type UpdateOneAgentMutationFn = Apollo.MutationFunction<UpdateOneAgentMutation, UpdateOneAgentMutationVariables>;
/**
* __useUpdateOneAgentMutation__
*
* To run a mutation, you first call `useUpdateOneAgentMutation` within a React component and pass it any options that fit your needs.
* When your component renders, `useUpdateOneAgentMutation` returns a tuple that includes:
* - A mutate function that you can call at any time to execute the mutation
* - An object with fields that represent the current status of the mutation's execution
*
* @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2;
*
* @example
* const [updateOneAgentMutation, { data, loading, error }] = useUpdateOneAgentMutation({
* variables: {
* input: // value for 'input'
* },
* });
*/
export function useUpdateOneAgentMutation(baseOptions?: Apollo.MutationHookOptions<UpdateOneAgentMutation, UpdateOneAgentMutationVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useMutation<UpdateOneAgentMutation, UpdateOneAgentMutationVariables>(UpdateOneAgentDocument, options);
}
export type UpdateOneAgentMutationHookResult = ReturnType<typeof useUpdateOneAgentMutation>;
export type UpdateOneAgentMutationResult = Apollo.MutationResult<UpdateOneAgentMutation>;
export type UpdateOneAgentMutationOptions = Apollo.BaseMutationOptions<UpdateOneAgentMutation, UpdateOneAgentMutationVariables>;
export const FindOneAgentDocument = gql`
query FindOneAgent($id: UUID!) {
findOneAgent(input: {id: $id}) {
id
name
description
prompt
modelId
responseFormat
roleId
}
}
`;
/**
* __useFindOneAgentQuery__
*
* To run a query within a React component, call `useFindOneAgentQuery` and pass it any options that fit your needs.
* When your component renders, `useFindOneAgentQuery` returns an object from Apollo Client that contains loading, error, and data properties
* you can use to render your UI.
*
* @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options;
*
* @example
* const { data, loading, error } = useFindOneAgentQuery({
* variables: {
* id: // value for 'id'
* },
* });
*/
export function useFindOneAgentQuery(baseOptions: Apollo.QueryHookOptions<FindOneAgentQuery, FindOneAgentQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useQuery<FindOneAgentQuery, FindOneAgentQueryVariables>(FindOneAgentDocument, options);
}
export function useFindOneAgentLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions<FindOneAgentQuery, FindOneAgentQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useLazyQuery<FindOneAgentQuery, FindOneAgentQueryVariables>(FindOneAgentDocument, options);
}
export type FindOneAgentQueryHookResult = ReturnType<typeof useFindOneAgentQuery>;
export type FindOneAgentLazyQueryHookResult = ReturnType<typeof useFindOneAgentLazyQuery>;
export type FindOneAgentQueryResult = Apollo.QueryResult<FindOneAgentQuery, FindOneAgentQueryVariables>;
export const GetAgentChatMessagesDocument = gql`
query GetAgentChatMessages($threadId: String!) {
agentChatMessages(threadId: $threadId) {
@ -8463,148 +8605,6 @@ export function useUpdateWorkflowVersionStepMutation(baseOptions?: Apollo.Mutati
export type UpdateWorkflowVersionStepMutationHookResult = ReturnType<typeof useUpdateWorkflowVersionStepMutation>;
export type UpdateWorkflowVersionStepMutationResult = Apollo.MutationResult<UpdateWorkflowVersionStepMutation>;
export type UpdateWorkflowVersionStepMutationOptions = Apollo.BaseMutationOptions<UpdateWorkflowVersionStepMutation, UpdateWorkflowVersionStepMutationVariables>;
export const AssignRoleToAgentDocument = gql`
mutation AssignRoleToAgent($agentId: UUID!, $roleId: UUID!) {
assignRoleToAgent(agentId: $agentId, roleId: $roleId)
}
`;
export type AssignRoleToAgentMutationFn = Apollo.MutationFunction<AssignRoleToAgentMutation, AssignRoleToAgentMutationVariables>;
/**
* __useAssignRoleToAgentMutation__
*
* To run a mutation, you first call `useAssignRoleToAgentMutation` within a React component and pass it any options that fit your needs.
* When your component renders, `useAssignRoleToAgentMutation` returns a tuple that includes:
* - A mutate function that you can call at any time to execute the mutation
* - An object with fields that represent the current status of the mutation's execution
*
* @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2;
*
* @example
* const [assignRoleToAgentMutation, { data, loading, error }] = useAssignRoleToAgentMutation({
* variables: {
* agentId: // value for 'agentId'
* roleId: // value for 'roleId'
* },
* });
*/
export function useAssignRoleToAgentMutation(baseOptions?: Apollo.MutationHookOptions<AssignRoleToAgentMutation, AssignRoleToAgentMutationVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useMutation<AssignRoleToAgentMutation, AssignRoleToAgentMutationVariables>(AssignRoleToAgentDocument, options);
}
export type AssignRoleToAgentMutationHookResult = ReturnType<typeof useAssignRoleToAgentMutation>;
export type AssignRoleToAgentMutationResult = Apollo.MutationResult<AssignRoleToAgentMutation>;
export type AssignRoleToAgentMutationOptions = Apollo.BaseMutationOptions<AssignRoleToAgentMutation, AssignRoleToAgentMutationVariables>;
export const RemoveRoleFromAgentDocument = gql`
mutation RemoveRoleFromAgent($agentId: UUID!) {
removeRoleFromAgent(agentId: $agentId)
}
`;
export type RemoveRoleFromAgentMutationFn = Apollo.MutationFunction<RemoveRoleFromAgentMutation, RemoveRoleFromAgentMutationVariables>;
/**
* __useRemoveRoleFromAgentMutation__
*
* To run a mutation, you first call `useRemoveRoleFromAgentMutation` within a React component and pass it any options that fit your needs.
* When your component renders, `useRemoveRoleFromAgentMutation` returns a tuple that includes:
* - A mutate function that you can call at any time to execute the mutation
* - An object with fields that represent the current status of the mutation's execution
*
* @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2;
*
* @example
* const [removeRoleFromAgentMutation, { data, loading, error }] = useRemoveRoleFromAgentMutation({
* variables: {
* agentId: // value for 'agentId'
* },
* });
*/
export function useRemoveRoleFromAgentMutation(baseOptions?: Apollo.MutationHookOptions<RemoveRoleFromAgentMutation, RemoveRoleFromAgentMutationVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useMutation<RemoveRoleFromAgentMutation, RemoveRoleFromAgentMutationVariables>(RemoveRoleFromAgentDocument, options);
}
export type RemoveRoleFromAgentMutationHookResult = ReturnType<typeof useRemoveRoleFromAgentMutation>;
export type RemoveRoleFromAgentMutationResult = Apollo.MutationResult<RemoveRoleFromAgentMutation>;
export type RemoveRoleFromAgentMutationOptions = Apollo.BaseMutationOptions<RemoveRoleFromAgentMutation, RemoveRoleFromAgentMutationVariables>;
export const UpdateOneAgentDocument = gql`
mutation UpdateOneAgent($input: UpdateAgentInput!) {
updateOneAgent(input: $input) {
id
name
description
prompt
modelId
responseFormat
}
}
`;
export type UpdateOneAgentMutationFn = Apollo.MutationFunction<UpdateOneAgentMutation, UpdateOneAgentMutationVariables>;
/**
* __useUpdateOneAgentMutation__
*
* To run a mutation, you first call `useUpdateOneAgentMutation` within a React component and pass it any options that fit your needs.
* When your component renders, `useUpdateOneAgentMutation` returns a tuple that includes:
* - A mutate function that you can call at any time to execute the mutation
* - An object with fields that represent the current status of the mutation's execution
*
* @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2;
*
* @example
* const [updateOneAgentMutation, { data, loading, error }] = useUpdateOneAgentMutation({
* variables: {
* input: // value for 'input'
* },
* });
*/
export function useUpdateOneAgentMutation(baseOptions?: Apollo.MutationHookOptions<UpdateOneAgentMutation, UpdateOneAgentMutationVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useMutation<UpdateOneAgentMutation, UpdateOneAgentMutationVariables>(UpdateOneAgentDocument, options);
}
export type UpdateOneAgentMutationHookResult = ReturnType<typeof useUpdateOneAgentMutation>;
export type UpdateOneAgentMutationResult = Apollo.MutationResult<UpdateOneAgentMutation>;
export type UpdateOneAgentMutationOptions = Apollo.BaseMutationOptions<UpdateOneAgentMutation, UpdateOneAgentMutationVariables>;
export const FindOneAgentDocument = gql`
query FindOneAgent($id: UUID!) {
findOneAgent(input: {id: $id}) {
id
name
description
prompt
modelId
responseFormat
roleId
}
}
`;
/**
* __useFindOneAgentQuery__
*
* To run a query within a React component, call `useFindOneAgentQuery` and pass it any options that fit your needs.
* When your component renders, `useFindOneAgentQuery` returns an object from Apollo Client that contains loading, error, and data properties
* you can use to render your UI.
*
* @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options;
*
* @example
* const { data, loading, error } = useFindOneAgentQuery({
* variables: {
* id: // value for 'id'
* },
* });
*/
export function useFindOneAgentQuery(baseOptions: Apollo.QueryHookOptions<FindOneAgentQuery, FindOneAgentQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useQuery<FindOneAgentQuery, FindOneAgentQueryVariables>(FindOneAgentDocument, options);
}
export function useFindOneAgentLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions<FindOneAgentQuery, FindOneAgentQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useLazyQuery<FindOneAgentQuery, FindOneAgentQueryVariables>(FindOneAgentDocument, options);
}
export type FindOneAgentQueryHookResult = ReturnType<typeof useFindOneAgentQuery>;
export type FindOneAgentLazyQueryHookResult = ReturnType<typeof useFindOneAgentLazyQuery>;
export type FindOneAgentQueryResult = Apollo.QueryResult<FindOneAgentQuery, FindOneAgentQueryVariables>;
export const SubmitFormStepDocument = gql`
mutation SubmitFormStep($input: SubmitFormStepInput!) {
submitFormStep(input: $input)

View File

@ -3,8 +3,8 @@ import styled from '@emotion/styled';
import { Avatar, IconDotsVertical, IconSparkles } from 'twenty-ui/display';
import { LightCopyIconButton } from '@/object-record/record-field/components/LightCopyIconButton';
import { AgentChatFilePreview } from '@/workflow/workflow-steps/workflow-actions/ai-agent-action/components/AgentChatFilePreview';
import { AgentChatMessageRole } from '@/workflow/workflow-steps/workflow-actions/ai-agent-action/constants/agent-chat-message-role';
import { AgentChatFilePreview } from '@/ai/components/internal/AgentChatFilePreview';
import { AgentChatMessageRole } from '@/ai/constants/agent-chat-message-role';
import { AgentChatMessage } from '~/generated/graphql';
import { beautifyPastDateRelativeToNow } from '~/utils/date-utils';

View File

@ -7,7 +7,7 @@ import { useCreateNewAIChatThread } from '@/ai/hooks/useCreateNewAIChatThread';
import { useCommandMenu } from '@/command-menu/hooks/useCommandMenu';
import { CommandMenuPages } from '@/command-menu/types/CommandMenuPages';
import { ScrollWrapper } from '@/ui/utilities/scroll/components/ScrollWrapper';
import { AgentChatFileUpload } from '@/workflow/workflow-steps/workflow-actions/ai-agent-action/components/AgentChatFileUpload';
import { AgentChatFileUploadButton } from '@/ai/components/internal/AgentChatFileUploadButton';
import { AIChatEmptyState } from '@/ai/components/AIChatEmptyState';
import { AIChatMessage } from '@/ai/components/AIChatMessage';
@ -16,8 +16,12 @@ import { t } from '@lingui/core/macro';
import { useState } from 'react';
import { Button } from 'twenty-ui/input';
import { useAgentChat } from '../hooks/useAgentChat';
import { AIChatSkeletonLoader } from './AIChatSkeletonLoader';
import { AgentChatSelectedFilesPreview } from './AgentChatSelectedFilesPreview';
import { AIChatSkeletonLoader } from '@/ai/components/internal/AIChatSkeletonLoader';
import { AgentChatContextPreview } from '@/ai/components/internal/AgentChatContextPreview';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { contextStoreCurrentObjectMetadataItemIdComponentState } from '@/context-store/states/contextStoreCurrentObjectMetadataItemIdComponentState';
import { SendMessageWithRecordsContextButton } from '@/ai/components/internal/SendMessageWithRecordsContextButton';
import { SendMessageButton } from '@/ai/components/internal/SendMessageButton';
const StyledContainer = styled.div<{ isDraggingFile: boolean }>`
background: ${({ theme }) => theme.background.primary};
@ -63,10 +67,13 @@ export const AIChatTab = ({
}) => {
const [isDraggingFile, setIsDraggingFile] = useState(false);
const contextStoreCurrentObjectMetadataItemId = useRecoilComponentValueV2(
contextStoreCurrentObjectMetadataItemIdComponentState,
);
const {
messages,
isLoading,
handleSendMessage,
input,
handleInputChange,
agentStreamingMessage,
@ -105,7 +112,7 @@ export const AIChatTab = ({
{isLoading && messages.length === 0 && <AIChatSkeletonLoader />}
<StyledInputArea>
<AgentChatSelectedFilesPreview agentId={agentId} />
<AgentChatContextPreview agentId={agentId} />
<TextArea
textAreaId={`${agentId}-chat-input`}
placeholder={t`Enter a question...`}
@ -135,16 +142,12 @@ export const AIChatTab = ({
/>
</>
)}
<AgentChatFileUpload agentId={agentId} />
<Button
variant="primary"
accent="blue"
size="small"
hotkeys={input && !isLoading ? ['⏎'] : undefined}
disabled={!input || isLoading}
title={t`Send`}
onClick={handleSendMessage}
/>
<AgentChatFileUploadButton agentId={agentId} />
{contextStoreCurrentObjectMetadataItemId ? (
<SendMessageWithRecordsContextButton agentId={agentId} />
) : (
<SendMessageButton agentId={agentId} />
)}
</StyledButtonsContainer>
</StyledInputArea>
</>

View File

@ -5,7 +5,7 @@ import { AIChatThreadsListEffect } from '@/ai/components/AIChatThreadsListEffect
import { useCreateNewAIChatThread } from '@/ai/hooks/useCreateNewAIChatThread';
import { groupThreadsByDate } from '@/ai/utils/groupThreadsByDate';
import { useHotkeysOnFocusedElement } from '@/ui/utilities/hotkey/hooks/useHotkeysOnFocusedElement';
import { AIChatSkeletonLoader } from '@/workflow/workflow-steps/workflow-actions/ai-agent-action/components/AIChatSkeletonLoader';
import { AIChatSkeletonLoader } from '@/ai/components/internal/AIChatSkeletonLoader';
import { Key } from 'ts-key-enum';
import { capitalize } from 'twenty-shared/utils';
import { Button } from 'twenty-ui/input';

View File

@ -0,0 +1,93 @@
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { agentChatUploadedFilesComponentState } from '@/ai/states/agentChatUploadedFilesComponentState';
import styled from '@emotion/styled';
import { AgentChatFilePreview } from './AgentChatFilePreview';
import { contextStoreCurrentObjectMetadataItemIdComponentState } from '@/context-store/states/contextStoreCurrentObjectMetadataItemIdComponentState';
import { useLingui } from '@lingui/react/macro';
import { useRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentStateV2';
import { agentChatSelectedFilesComponentState } from '@/ai/states/agentChatSelectedFilesComponentState';
import { useDeleteFileMutation } from '~/generated-metadata/graphql';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { AgentChatContextRecordPreview } from '@/ai/components/internal/AgentChatContextRecordPreview';
const StyledContainer = styled.div`
display: flex;
flex-direction: column;
gap: ${({ theme }) => theme.spacing(1)};
width: 100%;
`;
const StyledPreviewsContainer = styled.div`
display: flex;
flex-direction: row;
flex-wrap: wrap;
gap: ${({ theme }) => theme.spacing(1)};
`;
export const AgentChatContextPreview = ({ agentId }: { agentId: string }) => {
const { t } = useLingui();
const [agentChatSelectedFiles, setAgentChatSelectedFiles] =
useRecoilComponentStateV2(agentChatSelectedFilesComponentState, agentId);
const [agentChatUploadedFiles, setAgentChatUploadedFiles] =
useRecoilComponentStateV2(agentChatUploadedFilesComponentState, agentId);
const { enqueueErrorSnackBar } = useSnackBar();
const [deleteFile] = useDeleteFileMutation();
const handleRemoveUploadedFile = async (fileId: string) => {
const originalFiles = agentChatUploadedFiles;
setAgentChatUploadedFiles(
agentChatUploadedFiles.filter((f) => f.id !== fileId),
);
try {
await deleteFile({ variables: { fileId } });
} catch (error) {
setAgentChatUploadedFiles(originalFiles);
enqueueErrorSnackBar({
message: t`Failed to remove file`,
});
}
};
const contextStoreCurrentObjectMetadataItemId = useRecoilComponentValueV2(
contextStoreCurrentObjectMetadataItemIdComponentState,
);
return (
<StyledContainer>
<StyledPreviewsContainer>
{agentChatSelectedFiles.map((file) => (
<AgentChatFilePreview
file={file}
key={file.name}
onRemove={() => {
setAgentChatSelectedFiles(
agentChatSelectedFiles.filter((f) => f.name !== file.name),
);
}}
isUploading
/>
))}
{agentChatUploadedFiles.map((file) => (
<AgentChatFilePreview
file={file}
key={file.id}
onRemove={() => handleRemoveUploadedFile(file.id)}
isUploading={false}
/>
))}
{contextStoreCurrentObjectMetadataItemId && (
<AgentChatContextRecordPreview
agentId={agentId}
contextStoreCurrentObjectMetadataItemId={
contextStoreCurrentObjectMetadataItemId
}
/>
)}
</StyledPreviewsContainer>
</StyledContainer>
);
};

View File

@ -0,0 +1,104 @@
import { useFindManyRecordsSelectedInContextStore } from '@/context-store/hooks/useFindManyRecordsSelectedInContextStore';
import { useObjectMetadataItemById } from '@/object-metadata/hooks/useObjectMetadataItemById';
import { useRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentStateV2';
import { isAgentChatCurrentContextActiveState } from '@/ai/states/isAgentChatCurrentContextActiveState';
import { MultipleAvatarChip } from 'twenty-ui/components';
import { t } from '@lingui/core/macro';
import { IconReload, IconX } from 'twenty-ui/display';
import { CommandMenuContextRecordChipAvatars } from '@/command-menu/components/CommandMenuContextRecordChipAvatars';
import { getSelectedRecordsContextText } from '@/command-menu/utils/getRecordContextText';
import styled from '@emotion/styled';
import { useTheme } from '@emotion/react';
const StyledRightIconContainer = styled.div`
display: flex;
border-left: 1px solid ${({ theme }) => theme.border.color.light};
svg {
cursor: pointer;
}
`;
const StyledChipWrapper = styled.div<{ isActive: boolean }>`
opacity: ${({ isActive }) => (isActive ? 1 : 0.7)};
`;
export const AgentChatContextRecordPreview = ({
agentId,
contextStoreCurrentObjectMetadataItemId,
}: {
agentId: string;
contextStoreCurrentObjectMetadataItemId: string;
}) => {
const theme = useTheme();
const { records, totalCount } = useFindManyRecordsSelectedInContextStore({
limit: 3,
});
const { objectMetadataItem } = useObjectMetadataItemById({
objectId: contextStoreCurrentObjectMetadataItemId,
});
const [isAgentChatCurrentContextActive, setIsAgentChatCurrentContextActive] =
useRecoilComponentStateV2(isAgentChatCurrentContextActiveState, agentId);
const Avatars = records.map((record) => (
// @todo move this components to be less specific. (Outside of CommandMenu
<CommandMenuContextRecordChipAvatars
objectMetadataItem={objectMetadataItem}
key={record.id}
record={record}
/>
));
const recordSelectionContextChip = {
// @todo move this utils outside of CommandMenu
text: getSelectedRecordsContextText(
objectMetadataItem,
records,
totalCount ?? 0,
),
Icons: Avatars,
withIconBackground: false,
};
const toggleIsAgentChatCurrentContextActive = () => {
setIsAgentChatCurrentContextActive(!isAgentChatCurrentContextActive);
};
return (
<>
{records.length !== 0 && (
<StyledChipWrapper isActive={isAgentChatCurrentContextActive}>
<MultipleAvatarChip
Icons={recordSelectionContextChip.Icons}
text={
isAgentChatCurrentContextActive
? recordSelectionContextChip.text
: t`Context`
}
maxWidth={180}
rightComponent={
<StyledRightIconContainer>
{isAgentChatCurrentContextActive ? (
<IconX
size={theme.icon.size.sm}
color={theme.font.color.secondary}
onClick={toggleIsAgentChatCurrentContextActive}
/>
) : (
<IconReload
size={theme.icon.size.sm}
color={theme.font.color.secondary}
onClick={toggleIsAgentChatCurrentContextActive}
/>
)}
</StyledRightIconContainer>
}
/>
</StyledChipWrapper>
)}
</>
);
};

View File

@ -0,0 +1,47 @@
import { getFileType } from '@/activities/files/utils/getFileType';
import { IconMapping, useFileTypeColors } from '@/file/utils/fileIconMappings';
import { useTheme } from '@emotion/react';
import { File as FileDocument } from '~/generated-metadata/graphql';
import { Chip, ChipVariant, AvatarChip } from 'twenty-ui/components';
import { IconX } from 'twenty-ui/display';
import { Loader } from 'twenty-ui/feedback';
export const AgentChatFilePreview = ({
file,
onRemove,
isUploading,
}: {
file: File | FileDocument;
onRemove?: () => void;
isUploading?: boolean;
}) => {
const theme = useTheme();
const iconColors = useFileTypeColors();
return (
<Chip
label={file.name}
variant={ChipVariant.Static}
leftComponent={
isUploading ? (
<Loader color="yellow" />
) : (
<AvatarChip
Icon={IconMapping[getFileType(file.name)]}
IconBackgroundColor={iconColors[getFileType(file.name)]}
/>
)
}
rightComponent={
onRemove ? (
<AvatarChip
Icon={IconX}
IconColor={theme.font.color.secondary}
onClick={onRemove}
divider={'left'}
/>
) : undefined
}
/>
);
};

View File

@ -1,10 +1,10 @@
import { useAIChatFileUpload } from '@/ai/hooks/useAIChatFileUpload';
import { useRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentStateV2';
import { agentChatSelectedFilesComponentState } from '@/workflow/workflow-steps/workflow-actions/ai-agent-action/states/agentChatSelectedFilesComponentState';
import { agentChatSelectedFilesComponentState } from '@/ai/states/agentChatSelectedFilesComponentState';
import styled from '@emotion/styled';
import React, { useRef } from 'react';
import { IconPaperclip } from 'twenty-ui/display';
import { Button } from 'twenty-ui/input';
import { useSetRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentStateV2';
const StyledFileUploadContainer = styled.div`
display: flex;
@ -16,8 +16,8 @@ const StyledFileInput = styled.input`
display: none;
`;
export const AgentChatFileUpload = ({ agentId }: { agentId: string }) => {
const [, setAgentChatSelectedFiles] = useRecoilComponentStateV2(
export const AgentChatFileUploadButton = ({ agentId }: { agentId: string }) => {
const setAgentChatSelectedFiles = useSetRecoilComponentStateV2(
agentChatSelectedFilesComponentState,
agentId,
);

View File

@ -0,0 +1,29 @@
import { Button } from 'twenty-ui/input';
import { t } from '@lingui/core/macro';
import { useAgentChat } from '@/ai/hooks/useAgentChat';
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
export const SendMessageButton = ({
records,
agentId,
}: {
agentId: string;
records?: ObjectRecord[];
}) => {
const { isLoading, handleSendMessage, input } = useAgentChat(
agentId,
records,
);
return (
<Button
variant="primary"
accent="blue"
size="small"
hotkeys={input && !isLoading ? ['⏎'] : undefined}
disabled={!input || isLoading}
title={t`Send`}
onClick={handleSendMessage}
/>
);
};

View File

@ -0,0 +1,14 @@
import { useFindManyRecordsSelectedInContextStore } from '@/context-store/hooks/useFindManyRecordsSelectedInContextStore';
import { SendMessageButton } from '@/ai/components/internal/SendMessageButton';
export const SendMessageWithRecordsContextButton = ({
agentId,
}: {
agentId: string;
}) => {
const { records } = useFindManyRecordsSelectedInContextStore({
limit: 10,
});
return <SendMessageButton records={records} agentId={agentId} />;
};

View File

@ -1,8 +1,8 @@
import { useApolloCoreClient } from '@/object-metadata/hooks/useApolloCoreClient';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { useRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentStateV2';
import { agentChatSelectedFilesComponentState } from '@/workflow/workflow-steps/workflow-actions/ai-agent-action/states/agentChatSelectedFilesComponentState';
import { agentChatUploadedFilesComponentState } from '@/workflow/workflow-steps/workflow-actions/ai-agent-action/states/agentChatUploadedFilesComponentState';
import { agentChatSelectedFilesComponentState } from '@/ai/states/agentChatSelectedFilesComponentState';
import { agentChatUploadedFilesComponentState } from '@/ai/states/agentChatUploadedFilesComponentState';
import { useLingui } from '@lingui/react/macro';
import { isDefined } from 'twenty-shared/utils';
import {

View File

@ -8,10 +8,10 @@ import { currentAIChatThreadComponentState } from '@/ai/states/currentAIChatThre
import { useHotkeysOnFocusedElement } from '@/ui/utilities/hotkey/hooks/useHotkeysOnFocusedElement';
import { useScrollWrapperElement } from '@/ui/utilities/scroll/hooks/useScrollWrapperElement';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { STREAM_CHAT_QUERY } from '@/workflow/workflow-steps/workflow-actions/ai-agent-action/api/agent-chat-apollo.api';
import { AgentChatMessageRole } from '@/workflow/workflow-steps/workflow-actions/ai-agent-action/constants/agent-chat-message-role';
import { agentChatSelectedFilesComponentState } from '@/workflow/workflow-steps/workflow-actions/ai-agent-action/states/agentChatSelectedFilesComponentState';
import { agentChatUploadedFilesComponentState } from '@/workflow/workflow-steps/workflow-actions/ai-agent-action/states/agentChatUploadedFilesComponentState';
import { STREAM_CHAT_QUERY } from '@/ai/rest-api/agent-chat-apollo.api';
import { AgentChatMessageRole } from '@/ai/constants/agent-chat-message-role';
import { agentChatSelectedFilesComponentState } from '@/ai/states/agentChatSelectedFilesComponentState';
import { agentChatUploadedFilesComponentState } from '@/ai/states/agentChatUploadedFilesComponentState';
import { useApolloClient } from '@apollo/client';
import { isDefined } from 'twenty-shared/utils';
import { v4 } from 'uuid';
@ -24,20 +24,43 @@ import { agentChatInputState } from '../states/agentChatInputState';
import { agentChatMessagesComponentState } from '../states/agentChatMessagesComponentState';
import { agentStreamingMessageState } from '../states/agentStreamingMessageState';
import { parseAgentStreamingChunk } from '../utils/parseAgentStreamingChunk';
import {
agentChatObjectMetadataAndRecordContextState,
AIChatObjectMetadataAndRecordContext,
} from '@/ai/states/agentChatObjectMetadataAndRecordContextState';
import { contextStoreCurrentObjectMetadataItemIdComponentState } from '@/context-store/states/contextStoreCurrentObjectMetadataItemIdComponentState';
import { useGetObjectMetadataItemById } from '@/object-metadata/hooks/useGetObjectMetadataItemById';
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
import { isAgentChatCurrentContextActiveState } from '@/ai/states/isAgentChatCurrentContextActiveState';
type OptimisticMessage = AgentChatMessage & {
isPending: boolean;
};
export const useAgentChat = (agentId: string) => {
export const useAgentChat = (agentId: string, records?: ObjectRecord[]) => {
const apolloClient = useApolloClient();
const { enqueueErrorSnackBar } = useSnackBar();
const { getObjectMetadataItemById } = useGetObjectMetadataItemById();
const contextStoreCurrentObjectMetadataItemId = useRecoilComponentValueV2(
contextStoreCurrentObjectMetadataItemIdComponentState,
);
const isAgentChatCurrentContextActive = useRecoilComponentValueV2(
isAgentChatCurrentContextActiveState,
agentId,
);
const agentChatSelectedFiles = useRecoilComponentValueV2(
agentChatSelectedFilesComponentState,
agentId,
);
const [agentChatContext, setAgentChatContext] = useRecoilComponentStateV2(
agentChatObjectMetadataAndRecordContextState,
agentId,
);
const [currentThreadId, setCurrentThreadId] = useRecoilComponentStateV2(
currentAIChatThreadComponentState,
agentId,
@ -128,6 +151,21 @@ export const useAgentChat = (agentId: string) => {
setIsStreaming(true);
const recordIdsByObjectMetadataNameSingular = [];
if (
isAgentChatCurrentContextActive === true &&
isDefined(records) &&
isDefined(contextStoreCurrentObjectMetadataItemId)
) {
recordIdsByObjectMetadataNameSingular.push({
objectMetadataNameSingular: getObjectMetadataItemById(
contextStoreCurrentObjectMetadataItemId,
).nameSingular,
recordIds: records.map(({ id }) => id),
});
}
await apolloClient.query({
query: STREAM_CHAT_QUERY,
variables: {
@ -135,6 +173,8 @@ export const useAgentChat = (agentId: string) => {
threadId: currentThreadId,
userMessage: content,
fileIds: agentChatUploadedFiles.map((file) => file.id),
recordIdsByObjectMetadataNameSingular:
recordIdsByObjectMetadataNameSingular,
},
},
context: {
@ -200,6 +240,12 @@ export const useAgentChat = (agentId: string) => {
await sendChatMessage(content);
};
const handleSetContext = async (
items: Array<AIChatObjectMetadataAndRecordContext>,
) => {
setAgentChatContext(items);
};
useHotkeysOnFocusedElement({
keys: [Key.Enter],
callback: (event: KeyboardEvent) => {
@ -219,6 +265,8 @@ export const useAgentChat = (agentId: string) => {
handleInputChange: (value: string) => setAgentChatInput(value),
messages: agentChatMessages,
input: agentChatInput,
context: agentChatContext,
handleSetContext,
handleSendMessage,
isLoading,
agentStreamingMessage,

View File

@ -1,5 +1,5 @@
import { WorkflowAiAgentAction } from '@/workflow/types/Workflow';
import { OutputSchemaField } from '@/workflow/workflow-steps/workflow-actions/ai-agent-action/constants/output-field-type-options';
import { OutputSchemaField } from '@/ai/constants/output-field-type-options';
import { BaseOutputSchema } from '@/workflow/workflow-variables/types/StepOutputSchema';
import { useState } from 'react';
import { isDefined } from 'twenty-shared/utils';

View File

@ -0,0 +1,14 @@
import { AgentChatMessagesComponentInstanceContext } from '@/ai/states/agentChatMessagesComponentState';
import { createComponentStateV2 } from '@/ui/utilities/state/component-state/utils/createComponentStateV2';
export type AIChatObjectMetadataAndRecordContext = {
type: 'objectMetadataId' | 'recordId';
id: string;
};
export const agentChatObjectMetadataAndRecordContextState =
createComponentStateV2<Array<AIChatObjectMetadataAndRecordContext>>({
defaultValue: [],
key: 'agentChatObjectMetadataAndRecordContextState',
componentInstanceContext: AgentChatMessagesComponentInstanceContext,
});

View File

@ -1,5 +1,5 @@
import { createComponentStateV2 } from '@/ui/utilities/state/component-state/utils/createComponentStateV2';
import { AgentChatMessagesComponentInstanceContext } from '@/workflow/workflow-steps/workflow-actions/ai-agent-action/states/agentChatMessagesComponentState';
import { AgentChatMessagesComponentInstanceContext } from '@/ai/states/agentChatMessagesComponentState';
export const agentChatSelectedFilesComponentState = createComponentStateV2<
File[]

View File

@ -1,5 +1,5 @@
import { createComponentStateV2 } from '@/ui/utilities/state/component-state/utils/createComponentStateV2';
import { AgentChatMessagesComponentInstanceContext } from '@/workflow/workflow-steps/workflow-actions/ai-agent-action/states/agentChatMessagesComponentState';
import { AgentChatMessagesComponentInstanceContext } from '@/ai/states/agentChatMessagesComponentState';
import { File } from '~/generated-metadata/graphql';
export const agentChatUploadedFilesComponentState = createComponentStateV2<

View File

@ -0,0 +1,12 @@
import { createComponentStateV2 } from '@/ui/utilities/state/component-state/utils/createComponentStateV2';
import { createComponentInstanceContext } from '@/ui/utilities/state/component-state/utils/createComponentInstanceContext';
export const IsAgentChatCurrentContextActiveInstanceContext =
createComponentInstanceContext();
export const isAgentChatCurrentContextActiveState =
createComponentStateV2<boolean>({
defaultValue: true,
key: 'isAgentChatCurrentContextActiveState',
componentInstanceContext: IsAgentChatCurrentContextActiveInstanceContext,
});

View File

@ -1,88 +0,0 @@
import styled from '@emotion/styled';
import { isNonEmptyString } from '@sniptt/guards';
import { Fragment } from 'react/jsx-runtime';
import { isDefined } from 'twenty-shared/utils';
import { OverflowingTextWithTooltip } from 'twenty-ui/display';
const StyledChip = styled.button<{
withText: boolean;
maxWidth?: string;
onClick?: () => void;
}>`
all: unset;
align-items: center;
justify-content: center;
background: ${({ theme }) => theme.background.transparent.light};
border: 1px solid ${({ theme }) => theme.border.color.medium};
border-radius: ${({ theme }) => theme.border.radius.md};
box-sizing: border-box;
display: flex;
gap: ${({ theme }) => theme.spacing(1)};
height: ${({ theme }) => theme.spacing(6)};
/* If the chip has text, we add extra padding to have a more balanced design */
padding: 0
${({ theme, withText }) => (withText ? theme.spacing(2) : theme.spacing(1))};
font-family: inherit;
font-size: ${({ theme }) => theme.font.size.sm};
font-weight: ${({ theme }) => theme.font.weight.medium};
line-height: ${({ theme }) => theme.text.lineHeight.lg};
color: ${({ theme }) => theme.font.color.primary};
cursor: ${({ onClick }) => (isDefined(onClick) ? 'pointer' : 'default')};
&:hover {
background: ${({ onClick, theme }) =>
isDefined(onClick)
? theme.background.transparent.medium
: theme.background.transparent.light};
}
max-width: ${({ maxWidth }) => maxWidth};
`;
const StyledIconsContainer = styled.div`
align-items: center;
display: flex;
`;
const StyledEmptyText = styled.div`
color: ${({ theme }) => theme.font.color.tertiary};
`;
export type CommandMenuContextChipProps = {
Icons: React.ReactNode[];
text?: string;
onClick?: () => void;
testId?: string;
maxWidth?: string;
forceEmptyText?: boolean;
};
export const CommandMenuContextChip = ({
Icons,
text,
onClick,
testId,
maxWidth,
forceEmptyText = false,
}: CommandMenuContextChipProps) => {
return (
<StyledChip
withText={isNonEmptyString(text)}
onClick={onClick}
data-testid={testId}
maxWidth={maxWidth}
>
<StyledIconsContainer>
{Icons.map((Icon, index) => (
<Fragment key={index}>{Icon}</Fragment>
))}
</StyledIconsContainer>
{text?.trim?.() ? (
<OverflowingTextWithTooltip text={text} />
) : !forceEmptyText ? (
<StyledEmptyText>Untitled</StyledEmptyText>
) : (
''
)}
</StyledChip>
);
};

View File

@ -6,14 +6,14 @@ import { useCloseDropdown } from '@/ui/layout/dropdown/hooks/useCloseDropdown';
import { isDefined } from 'twenty-shared/utils';
import { MenuItem } from 'twenty-ui/navigation';
import {
CommandMenuContextChip,
CommandMenuContextChipProps,
} from './CommandMenuContextChip';
MultipleAvatarChip,
MultipleAvatarChipProps,
} from 'twenty-ui/components';
export const CommandMenuContextChipGroups = ({
contextChips,
}: {
contextChips: CommandMenuContextChipProps[];
contextChips: MultipleAvatarChipProps[];
}) => {
const { closeDropdown } = useCloseDropdown();
@ -25,9 +25,9 @@ export const CommandMenuContextChipGroups = ({
return (
<>
{contextChips.map((chip, index) => (
<CommandMenuContextChip
<MultipleAvatarChip
key={index}
maxWidth={'180px'}
maxWidth={180}
Icons={chip.Icons}
text={chip.text}
onClick={chip.onClick}
@ -46,7 +46,7 @@ export const CommandMenuContextChipGroups = ({
{firstChips.length > 0 && (
<Dropdown
clickableComponent={
<CommandMenuContextChip
<MultipleAvatarChip
Icons={firstThreeChips.map((chip) => chip.Icons?.[0])}
onClick={() => {}}
text={`${firstChips.length}`}
@ -77,11 +77,11 @@ export const CommandMenuContextChipGroups = ({
)}
{isDefined(lastChip) && (
<CommandMenuContextChip
<MultipleAvatarChip
Icons={lastChip.Icons}
text={lastChip.text}
onClick={lastChip.onClick}
maxWidth={'180px'}
maxWidth={180}
/>
)}
</>

View File

@ -4,14 +4,14 @@ import { useCommandMenu } from '@/command-menu/hooks/useCommandMenu';
import { getSelectedRecordsContextText } from '@/command-menu/utils/getRecordContextText';
import { useFindManyRecordsSelectedInContextStore } from '@/context-store/hooks/useFindManyRecordsSelectedInContextStore';
import { useObjectMetadataItemById } from '@/object-metadata/hooks/useObjectMetadataItemById';
import { CommandMenuContextChipProps } from './CommandMenuContextChip';
import { isDefined } from 'twenty-shared/utils';
import { MultipleAvatarChipProps } from 'twenty-ui/components';
export const CommandMenuContextChipGroupsWithRecordSelection = ({
contextChips,
objectMetadataItemId,
}: {
contextChips: CommandMenuContextChipProps[];
contextChips: MultipleAvatarChipProps[];
objectMetadataItemId: string;
}) => {
const { objectMetadataItem } = useObjectMetadataItemById({

View File

@ -1,8 +1,8 @@
import { CommandMenuContextChip } from '@/command-menu/components/CommandMenuContextChip';
import { CommandMenuContextRecordChipAvatars } from '@/command-menu/components/CommandMenuContextRecordChipAvatars';
import { getSelectedRecordsContextText } from '@/command-menu/utils/getRecordContextText';
import { useFindManyRecordsSelectedInContextStore } from '@/context-store/hooks/useFindManyRecordsSelectedInContextStore';
import { useObjectMetadataItemById } from '@/object-metadata/hooks/useObjectMetadataItemById';
import { MultipleAvatarChip } from 'twenty-ui/components';
export const CommandMenuContextRecordsChip = ({
objectMetadataItemId,
@ -34,7 +34,7 @@ export const CommandMenuContextRecordsChip = ({
));
return (
<CommandMenuContextChip
<MultipleAvatarChip
text={getSelectedRecordsContextText(
objectMetadataItem,
records,

View File

@ -1,4 +1,3 @@
import { CommandMenuContextChip } from '@/command-menu/components/CommandMenuContextChip';
import { CommandMenuContextChipGroups } from '@/command-menu/components/CommandMenuContextChipGroups';
import { CommandMenuContextChipGroupsWithRecordSelection } from '@/command-menu/components/CommandMenuContextChipGroupsWithRecordSelection';
import { CommandMenuTopBarInputFocusEffect } from '@/command-menu/components/CommandMenuTopBarInputFocusEffect';
@ -22,6 +21,7 @@ import { useLocation } from 'react-router-dom';
import { useRecoilState, useRecoilValue } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
import { IconChevronLeft, IconX } from 'twenty-ui/display';
import { MultipleAvatarChip } from 'twenty-ui/components';
import { Button } from 'twenty-ui/input';
import { getOsControlSymbol, useIsMobile } from 'twenty-ui/utilities';
@ -122,7 +122,7 @@ export const CommandMenuTopBar = () => {
duration: backButtonAnimationDuration,
}}
>
<CommandMenuContextChip
<MultipleAvatarChip
Icons={[<IconChevronLeft size={theme.icon.size.sm} />]}
onClick={goBackFromCommandMenu}
testId="command-menu-go-back-button"

View File

@ -1,5 +1,5 @@
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { AIChatTab } from '@/workflow/workflow-steps/workflow-actions/ai-agent-action/components/AIChatTab';
import { AIChatTab } from '@/ai/components/AIChatTab';
import styled from '@emotion/styled';
import { useRecoilValue } from 'recoil';

View File

@ -1,19 +1,8 @@
import { AttachmentType } from '@/activities/files/types/Attachment';
import { IconMapping, useFileTypeColors } from '@/file/utils/fileIconMappings';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import {
IconComponent,
IconFile,
IconFileText,
IconFileZip,
IconHeadphones,
IconPhoto,
IconPresentation,
IconTable,
IconVideo,
} from 'twenty-ui/display';
const StyledIconContainer = styled.div<{ background: string }>`
align-items: center;
background: ${({ background }) => background};
@ -25,35 +14,14 @@ const StyledIconContainer = styled.div<{ background: string }>`
padding: ${({ theme }) => theme.spacing(1.25)};
`;
const IconMapping: { [key in AttachmentType]: IconComponent } = {
Archive: IconFileZip,
Audio: IconHeadphones,
Image: IconPhoto,
Presentation: IconPresentation,
Spreadsheet: IconTable,
TextDocument: IconFileText,
Video: IconVideo,
Other: IconFile,
};
export const FileIcon = ({ fileType }: { fileType: AttachmentType }) => {
const theme = useTheme();
const IconColors: { [key in AttachmentType]: string } = {
Archive: theme.color.gray,
Audio: theme.color.pink,
Image: theme.color.yellow,
Presentation: theme.color.orange,
Spreadsheet: theme.color.turquoise,
TextDocument: theme.color.blue,
Video: theme.color.purple,
Other: theme.color.gray,
};
const iconColors = useFileTypeColors();
const Icon = IconMapping[fileType];
return (
<StyledIconContainer background={IconColors[fileType]}>
<StyledIconContainer background={iconColors[fileType]}>
{Icon && <Icon size={theme.icon.size.sm} />}
</StyledIconContainer>
);

View File

@ -0,0 +1,40 @@
import { AttachmentType } from '@/activities/files/types/Attachment';
import { useTheme } from '@emotion/react';
import {
IconComponent,
IconFile,
IconFileText,
IconFileZip,
IconHeadphones,
IconPhoto,
IconPresentation,
IconTable,
IconVideo,
} from 'twenty-ui/display';
export const IconMapping: { [key in AttachmentType]: IconComponent } = {
Archive: IconFileZip,
Audio: IconHeadphones,
Image: IconPhoto,
Presentation: IconPresentation,
Spreadsheet: IconTable,
TextDocument: IconFileText,
Video: IconVideo,
Other: IconFile,
};
const getIconColors = (theme: any): { [key in AttachmentType]: string } => ({
Archive: theme.color.gray,
Audio: theme.color.pink,
Image: theme.color.yellow,
Presentation: theme.color.orange,
Spreadsheet: theme.color.turquoise,
TextDocument: theme.color.blue,
Video: theme.color.purple,
Other: theme.color.gray,
});
export const useFileTypeColors = () => {
const theme = useTheme();
return getIconColors(theme);
};

View File

@ -0,0 +1,28 @@
import { useRecoilValue } from 'recoil';
import { CustomError } from '@/error-handler/CustomError';
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
import { isDefined } from 'twenty-shared/utils';
export const useGetObjectMetadataItemById = () => {
const objectMetadataItems = useRecoilValue(objectMetadataItemsState);
const getObjectMetadataItemById = (objectId: string) => {
const objectMetadataItem = objectMetadataItems.find(
(objectMetadataItem) => objectMetadataItem.id === objectId,
);
if (!isDefined(objectMetadataItem)) {
throw new CustomError(
`Object metadata item not found for id ${objectId}`,
'OBJECT_METADATA_ITEM_NOT_FOUND',
);
}
return objectMetadataItem;
};
return {
getObjectMetadataItemById,
};
};

View File

@ -10,11 +10,11 @@ import { MouseEvent } from 'react';
import { useRecoilValue } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
import {
Chip,
AvatarChip,
AvatarChipVariant,
ChipSize,
ChipVariant,
LinkAvatarChip,
LinkChip,
} from 'twenty-ui/components';
import { TriggerEventType } from 'twenty-ui/utilities';
@ -22,7 +22,7 @@ export type RecordChipProps = {
objectNameSingular: string;
record: ObjectRecord;
className?: string;
variant?: AvatarChipVariant;
variant?: ChipVariant.Highlighted | ChipVariant.Transparent;
forceDisableClick?: boolean;
maxWidth?: number;
to?: string | undefined;
@ -72,39 +72,42 @@ export const RecordChip = ({
// TODO temporary until we create a record show page for Workspaces members
const avatarChip = (
<AvatarChip
placeholder={recordChipData.name}
placeholderColorSeed={record.id}
avatarType={recordChipData.avatarType}
avatarUrl={recordChipData.avatarUrl ?? ''}
/>
);
if (
forceDisableClick ||
objectNameSingular === CoreObjectNameSingular.WorkspaceMember
) {
return (
<AvatarChip
<Chip
label={recordChipData.name}
size={size}
maxWidth={maxWidth}
placeholderColorSeed={record.id}
name={recordChipData.name}
avatarType={recordChipData.avatarType}
avatarUrl={recordChipData.avatarUrl ?? ''}
className={className}
variant={ChipVariant.Transparent}
leftComponent={avatarChip}
/>
);
}
return (
<LinkAvatarChip
<LinkChip
size={size}
maxWidth={maxWidth}
placeholderColorSeed={record.id}
name={recordChipData.name}
label={recordChipData.name}
isLabelHidden={isLabelHidden}
avatarType={recordChipData.avatarType}
avatarUrl={recordChipData.avatarUrl ?? ''}
leftComponent={avatarChip}
className={className}
variant={
variant ??
(!forceDisableClick
? AvatarChipVariant.Regular
: AvatarChipVariant.Transparent)
(!forceDisableClick ? ChipVariant.Highlighted : ChipVariant.Transparent)
}
to={to ?? getLinkToShowPage(objectNameSingular, record)}
onClick={handleCustomClick}

View File

@ -19,9 +19,9 @@ import styled from '@emotion/styled';
import { Dispatch, SetStateAction, useContext } from 'react';
import { useRecoilValue } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
import { AvatarChipVariant } from 'twenty-ui/components';
import { IconEye, IconEyeOff } from 'twenty-ui/display';
import { Checkbox, CheckboxVariant, LightIconButton } from 'twenty-ui/input';
import { ChipVariant } from 'twenty-ui/components';
const StyledCompactIconContainer = styled.div`
align-items: center;
@ -80,7 +80,7 @@ export const RecordBoardCardHeader = ({
<RecordChip
objectNameSingular={objectMetadataItem.nameSingular}
record={record}
variant={AvatarChipVariant.Transparent}
variant={ChipVariant.Transparent}
maxWidth={150}
onClick={() => {
activateBoardCard({ rowIndex, columnIndex });

View File

@ -20,7 +20,7 @@ import { isNonEmptyString } from '@sniptt/guards';
import { useState } from 'react';
import { useRecoilState, useRecoilValue } from 'recoil';
import { getImageAbsoluteURI, isDefined } from 'twenty-shared/utils';
import { AvatarChip } from 'twenty-ui/components';
import { Chip, AvatarChip } from 'twenty-ui/components';
import {
H2Title,
IconEyeShare,
@ -137,15 +137,19 @@ export const SettingsAdminWorkspaceContent = ({
Icon: IconHome,
label: t`Name`,
value: (
<AvatarChip
name={activeWorkspace?.name ?? ''}
avatarUrl={
getImageAbsoluteURI({
imageUrl: isNonEmptyString(activeWorkspace?.logo)
? activeWorkspace?.logo
: DEFAULT_WORKSPACE_LOGO,
baseUrl: REACT_APP_SERVER_BASE_URL,
}) ?? ''
<Chip
label={activeWorkspace?.name ?? ''}
leftComponent={
<AvatarChip
avatarUrl={
getImageAbsoluteURI({
imageUrl: isNonEmptyString(activeWorkspace?.logo)
? activeWorkspace?.logo
: DEFAULT_WORKSPACE_LOGO,
baseUrl: REACT_APP_SERVER_BASE_URL,
}) ?? ''
}
/>
}
/>
),

View File

@ -2,7 +2,7 @@ import { FieldActorValue } from '@/object-record/record-field/types/FieldMetadat
import { useMemo } from 'react';
import { ConnectedAccountProvider } from 'twenty-shared/types';
import { AvatarChip } from 'twenty-ui/components';
import { Chip, AvatarChip } from 'twenty-ui/components';
import {
IconApi,
IconCalendar,
@ -71,13 +71,18 @@ export const ActorDisplay = ({
source === 'API' || source === 'IMPORT' || source === 'SYSTEM';
return (
<AvatarChip
placeholderColorSeed={workspaceMemberId ?? undefined}
name={name ?? ''}
avatarType={workspaceMemberId ? 'rounded' : 'squared'}
LeftIcon={LeftIcon}
avatarUrl={avatarUrl ?? undefined}
isIconInverted={isIconInverted}
<Chip
label={name ?? ''}
leftComponent={
<AvatarChip
placeholderColorSeed={workspaceMemberId ?? undefined}
avatarType={workspaceMemberId ? 'rounded' : 'squared'}
placeholder={name}
Icon={LeftIcon}
avatarUrl={avatarUrl ?? undefined}
isIconInverted={isIconInverted}
/>
}
/>
);
};

View File

@ -1,103 +0,0 @@
import { getFileType } from '@/activities/files/utils/getFileType';
import { FileIcon } from '@/file/components/FileIcon';
import { formatFileSize } from '@/file/utils/formatFileSize';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { IconX } from 'twenty-ui/display';
import { Loader } from 'twenty-ui/feedback';
import { File as FileDocument } from '~/generated-metadata/graphql';
const StyledFileChip = styled.div`
display: flex;
align-items: center;
background: ${({ theme }) => theme.background.transparent.lighter};
border-radius: ${({ theme }) => theme.border.radius.md};
height: 40px;
border: 1px solid ${({ theme }) => theme.border.color.light};
`;
const StyledFileIconContainer = styled.div`
align-items: center;
display: flex;
height: 100%;
justify-content: center;
padding-inline: ${({ theme }) => theme.spacing(2)};
overflow: hidden;
`;
const StyledFileInfo = styled.div`
height: 100%;
display: flex;
justify-content: center;
flex: 1;
min-width: 0;
flex-direction: column;
gap: ${({ theme }) => theme.spacing(1)};
border-left: 1px solid ${({ theme }) => theme.border.color.light};
padding-inline: ${({ theme }) => theme.spacing(2)};
`;
const StyledFileName = styled.div`
color: ${({ theme }) => theme.font.color.secondary};
font-size: ${({ theme }) => theme.font.size.sm};
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 125px;
`;
const StyledFileSize = styled.div`
color: ${({ theme }) => theme.font.color.secondary};
font-size: ${({ theme }) => theme.font.size.sm};
`;
const StyledRemoveIconContainer = styled.div`
display: flex;
align-items: center;
justify-content: center;
height: 100%;
padding-inline: ${({ theme }) => theme.spacing(3)};
border-left: 1px solid ${({ theme }) => theme.border.color.light};
svg {
cursor: pointer;
}
`;
export const AgentChatFilePreview = ({
file,
onRemove,
isUploading,
}: {
file: File | FileDocument;
onRemove?: () => void;
isUploading?: boolean;
}) => {
const theme = useTheme();
return (
<StyledFileChip key={file.name}>
<StyledFileIconContainer>
{isUploading ? (
<Loader color="yellow" />
) : (
<FileIcon fileType={getFileType(file.name)} />
)}
</StyledFileIconContainer>
<StyledFileInfo>
<StyledFileName title={file.name}>{file.name}</StyledFileName>
<StyledFileSize>{formatFileSize(file.size)}</StyledFileSize>
</StyledFileInfo>
{onRemove && (
<StyledRemoveIconContainer>
<IconX
size={theme.icon.size.md}
color={theme.font.color.secondary}
onClick={onRemove}
/>
</StyledRemoveIconContainer>
)}
</StyledFileChip>
);
};

View File

@ -1,75 +0,0 @@
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { useRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentStateV2';
import { AgentChatFilePreview } from '@/workflow/workflow-steps/workflow-actions/ai-agent-action/components/AgentChatFilePreview';
import { agentChatSelectedFilesComponentState } from '@/workflow/workflow-steps/workflow-actions/ai-agent-action/states/agentChatSelectedFilesComponentState';
import { agentChatUploadedFilesComponentState } from '@/workflow/workflow-steps/workflow-actions/ai-agent-action/states/agentChatUploadedFilesComponentState';
import styled from '@emotion/styled';
import { useLingui } from '@lingui/react/macro';
import { useDeleteFileMutation } from '~/generated-metadata/graphql';
const StyledPreviewContainer = styled.div`
display: flex;
flex-direction: row;
flex-wrap: wrap;
gap: ${({ theme }) => theme.spacing(2)};
margin-bottom: ${({ theme }) => theme.spacing(2)};
width: 100%;
`;
export const AgentChatSelectedFilesPreview = ({
agentId,
}: {
agentId: string;
}) => {
const { t } = useLingui();
const [agentChatSelectedFiles, setAgentChatSelectedFiles] =
useRecoilComponentStateV2(agentChatSelectedFilesComponentState, agentId);
const [agentChatUploadedFiles, setAgentChatUploadedFiles] =
useRecoilComponentStateV2(agentChatUploadedFilesComponentState, agentId);
const { enqueueErrorSnackBar } = useSnackBar();
const [deleteFile] = useDeleteFileMutation();
const handleRemoveUploadedFile = async (fileId: string) => {
const originalFiles = agentChatUploadedFiles;
setAgentChatUploadedFiles(
agentChatUploadedFiles.filter((f) => f.id !== fileId),
);
try {
await deleteFile({ variables: { fileId } });
} catch (error) {
setAgentChatUploadedFiles(originalFiles);
enqueueErrorSnackBar({
message: t`Failed to remove file`,
});
}
};
return [...agentChatSelectedFiles, ...agentChatUploadedFiles].length > 0 ? (
<StyledPreviewContainer>
{agentChatSelectedFiles.map((file) => (
<AgentChatFilePreview
file={file}
key={file.name}
onRemove={() => {
setAgentChatSelectedFiles(
agentChatSelectedFiles.filter((f) => f.name !== file.name),
);
}}
isUploading
/>
))}
{agentChatUploadedFiles.map((file) => (
<AgentChatFilePreview
file={file}
key={file.id}
onRemove={() => handleRemoveUploadedFile(file.id)}
isUploading={false}
/>
))}
</StyledPreviewContainer>
) : null;
};

View File

@ -6,7 +6,7 @@ import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/
import { WorkflowAiAgentAction } from '@/workflow/types/Workflow';
import { WorkflowStepBody } from '@/workflow/workflow-steps/components/WorkflowStepBody';
import { WorkflowStepHeader } from '@/workflow/workflow-steps/components/WorkflowStepHeader';
import { AIChatTab } from '@/workflow/workflow-steps/workflow-actions/ai-agent-action/components/AIChatTab';
import { AIChatTab } from '@/ai/components/AIChatTab';
import { useWorkflowActionHeader } from '@/workflow/workflow-steps/workflow-actions/hooks/useWorkflowActionHeader';
import { WorkflowVariablePicker } from '@/workflow/workflow-variables/components/WorkflowVariablePicker';
import { BaseOutputSchema } from '@/workflow/workflow-variables/types/StepOutputSchema';
@ -23,10 +23,10 @@ import {
WORKFLOW_AI_AGENT_TAB_LIST_COMPONENT_ID,
WorkflowAiAgentTabId,
} from '../constants/workflow-ai-agent-tabs';
import { useAgentRoleAssignment } from '../hooks/useAgentRoleAssignment';
import { useAgentUpdateFormState } from '../hooks/useAgentUpdateFormState';
import { useAiAgentOutputSchema } from '../hooks/useAiAgentOutputSchema';
import { useAiModelOptions } from '../hooks/useAiModelOptions';
import { useAgentRoleAssignment } from '@/ai/hooks/useAgentRoleAssignment';
import { useAgentUpdateFormState } from '@/ai/hooks/useAgentUpdateFormState';
import { useAiAgentOutputSchema } from '@/ai/hooks/useAiAgentOutputSchema';
import { useAiModelOptions } from '@/ai/hooks/useAiModelOptions';
import { WorkflowOutputSchemaBuilder } from './WorkflowOutputSchemaBuilder';
const StyledErrorMessage = styled.div`

View File

@ -1,7 +1,7 @@
import { Select } from '@/ui/input/components/Select';
import { InputSchemaPropertyType } from '@/workflow/types/InputSchema';
import { t } from '@lingui/core/macro';
import { OUTPUT_FIELD_TYPE_OPTIONS } from '../constants/output-field-type-options';
import { OUTPUT_FIELD_TYPE_OPTIONS } from '@/ai/constants/output-field-type-options';
type WorkflowOutputFieldTypeSelectorProps = {
value?: InputSchemaPropertyType;

View File

@ -3,7 +3,7 @@ import { FormTextFieldInput } from '@/object-record/record-field/form-types/comp
import { InputLabel } from '@/ui/input/components/InputLabel';
import { InputSchemaPropertyType } from '@/workflow/types/InputSchema';
import { OutputSchemaField } from '@/workflow/workflow-steps/workflow-actions/ai-agent-action/constants/output-field-type-options';
import { OutputSchemaField } from '@/ai/constants/output-field-type-options';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { t } from '@lingui/core/macro';

View File

@ -15,6 +15,9 @@ import { RestApiExceptionFilter } from 'src/engine/api/rest/rest-api-exception.f
import { AuthUserWorkspaceId } from 'src/engine/decorators/auth/auth-user-workspace-id.decorator';
import { JwtAuthGuard } from 'src/engine/guards/jwt-auth.guard';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { RecordIdsByObjectMetadataNameSingularType } from 'src/engine/metadata-modules/agent/types/recordIdsByObjectMetadataNameSingular.type';
import { AgentChatService } from './agent-chat.service';
import { AgentStreamingService } from './agent-streaming.service';
@ -58,8 +61,14 @@ export class AgentChatController {
@Post('stream')
async streamAgentChat(
@Body()
body: { threadId: string; userMessage: string; fileIds?: string[] },
body: {
threadId: string;
userMessage: string;
fileIds?: string[];
recordIdsByObjectMetadataNameSingular?: RecordIdsByObjectMetadataNameSingularType;
},
@AuthUserWorkspaceId() userWorkspaceId: string,
@AuthWorkspace() workspace: Workspace,
@Res() res: Response,
) {
try {
@ -67,7 +76,10 @@ export class AgentChatController {
threadId: body.threadId,
userMessage: body.userMessage,
userWorkspaceId,
workspace,
fileIds: body.fileIds || [],
recordIdsByObjectMetadataNameSingular:
body.recordIdsByObjectMetadataNameSingular || [],
res,
});
} catch (error) {

View File

@ -13,7 +13,7 @@ import {
generateText,
ImagePart,
streamText,
TextPart,
UserContent,
} from 'ai';
import { In, Repository } from 'typeorm';
@ -37,9 +37,13 @@ import { convertOutputSchemaToZod } from 'src/engine/metadata-modules/agent/util
import { OutputSchema } from 'src/modules/workflow/workflow-builder/workflow-schema/types/output-schema.type';
import { resolveInput } from 'src/modules/workflow/workflow-executor/utils/variable-resolver.util';
import { streamToBuffer } from 'src/utils/stream-to-buffer';
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { RecordIdsByObjectMetadataNameSingularType } from 'src/engine/metadata-modules/agent/types/recordIdsByObjectMetadataNameSingular.type';
import { WorkspacePermissionsCacheService } from 'src/engine/metadata-modules/workspace-permissions-cache/workspace-permissions-cache.service';
import { AgentEntity } from './agent.entity';
import { AgentException, AgentExceptionCode } from './agent.exception';
import { AgentEntity } from './agent.entity';
export interface AgentExecutionResult {
result: {
@ -61,6 +65,8 @@ export class AgentExecutionService {
private readonly twentyConfigService: TwentyConfigService,
private readonly agentToolService: AgentToolService,
private readonly fileService: FileService,
private readonly twentyORMGlobalManager: TwentyORMGlobalManager,
private readonly workspacePermissionsCacheService: WorkspacePermissionsCacheService,
private readonly aiModelRegistryService: AiModelRegistryService,
@InjectRepository(AgentEntity, 'core')
private readonly agentRepository: Repository<AgentEntity>,
@ -184,34 +190,88 @@ export class AgentExecutionService {
}
private async buildUserMessageWithFiles(
userMessage: string,
fileIds?: string[],
): Promise<CoreUserMessage> {
if (!fileIds || fileIds.length === 0) {
return { role: AgentChatMessageRole.USER, content: userMessage };
}
fileIds: string[],
): Promise<(ImagePart | FilePart)[]> {
const files = await this.fileRepository.find({
where: {
id: In(fileIds),
},
});
const textPart: TextPart = {
type: 'text',
text: userMessage,
};
return await Promise.all(files.map((file) => this.createFilePart(file)));
}
const fileParts = await Promise.all(
files.map((file) => this.createFilePart(file)),
);
private async buildUserMessage(
userMessage: string,
fileIds: string[],
): Promise<CoreUserMessage> {
const content: Exclude<UserContent, string> = [
{
type: 'text',
text: userMessage,
},
];
if (fileIds.length !== 0) {
content.push(...(await this.buildUserMessageWithFiles(fileIds)));
}
return {
role: AgentChatMessageRole.USER,
content: [textPart, ...fileParts],
content,
};
}
private async getContextForSystemPrompt(
workspace: Workspace,
recordIdsByObjectMetadataNameSingular: RecordIdsByObjectMetadataNameSingularType,
userWorkspaceId: string,
) {
const roleId =
await this.workspacePermissionsCacheService.getRoleIdFromUserWorkspaceId({
workspaceId: workspace.id,
userWorkspaceId,
});
if (!roleId) {
throw new AgentException(
'Failed to retrieve user role.',
AgentExceptionCode.ROLE_NOT_FOUND,
);
}
const workspaceDataSource =
await this.twentyORMGlobalManager.getDataSourceForWorkspace({
workspaceId: workspace.id,
});
const contextObject = (
await Promise.all(
recordIdsByObjectMetadataNameSingular.map(
(recordsWithObjectMetadataNameSingular) => {
if (recordsWithObjectMetadataNameSingular.recordIds.length === 0) {
return [];
}
const repository = workspaceDataSource.getRepository(
recordsWithObjectMetadataNameSingular.objectMetadataNameSingular,
false,
roleId,
);
return repository.find({
where: {
id: In(recordsWithObjectMetadataNameSingular.recordIds),
},
});
},
),
)
).flat(2);
return JSON.stringify(contextObject);
}
private async createFilePart(
file: FileEntity,
): Promise<ImagePart | FilePart> {
@ -241,15 +301,21 @@ export class AgentExecutionService {
}
async streamChatResponse({
workspace,
userWorkspaceId,
agentId,
userMessage,
messages,
fileIds,
recordIdsByObjectMetadataNameSingular,
}: {
workspace: Workspace;
userWorkspaceId: string;
agentId: string;
userMessage: string;
messages: AgentChatMessageEntity[];
fileIds?: string[];
fileIds: string[];
recordIdsByObjectMetadataNameSingular: RecordIdsByObjectMetadataNameSingularType;
}) {
const agent = await this.agentRepository.findOneOrFail({
where: { id: agentId },
@ -260,7 +326,19 @@ export class AgentExecutionService {
content,
}));
const userMessageWithFiles = await this.buildUserMessageWithFiles(
let contextString = '';
if (recordIdsByObjectMetadataNameSingular.length > 0) {
const contextPart = await this.getContextForSystemPrompt(
workspace,
recordIdsByObjectMetadataNameSingular,
userWorkspaceId,
);
contextString = `\n\nCONTEXT:\n${contextPart}`;
}
const userMessageWithFiles = await this.buildUserMessage(
userMessage,
fileIds,
);
@ -268,7 +346,7 @@ export class AgentExecutionService {
llmMessages.push(userMessageWithFiles);
const aiRequestConfig = await this.prepareAIRequestConfig({
system: `${AGENT_SYSTEM_PROMPTS.AGENT_CHAT}\n\n${agent.prompt}`,
system: `${AGENT_SYSTEM_PROMPTS.AGENT_CHAT}\n\n${agent.prompt}${contextString}`,
agent,
messages: llmMessages,
});

View File

@ -12,21 +12,19 @@ import {
AgentException,
AgentExceptionCode,
} from 'src/engine/metadata-modules/agent/agent.exception';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { RecordIdsByObjectMetadataNameSingularType } from 'src/engine/metadata-modules/agent/types/recordIdsByObjectMetadataNameSingular.type';
export type StreamAgentChatOptions = {
threadId: string;
userMessage: string;
userWorkspaceId: string;
fileIds?: string[];
workspace: Workspace;
fileIds: string[];
recordIdsByObjectMetadataNameSingular: RecordIdsByObjectMetadataNameSingularType;
res: Response;
};
export type StreamAgentChatResult = {
success: boolean;
error?: string;
aiResponse?: string;
};
@Injectable()
export class AgentStreamingService {
private readonly logger = new Logger(AgentStreamingService.name);
@ -42,7 +40,9 @@ export class AgentStreamingService {
threadId,
userMessage,
userWorkspaceId,
fileIds = [],
workspace,
fileIds,
recordIdsByObjectMetadataNameSingular,
res,
}: StreamAgentChatOptions) {
try {
@ -65,10 +65,13 @@ export class AgentStreamingService {
const { fullStream } =
await this.agentExecutionService.streamChatResponse({
workspace,
agentId: thread.agent.id,
userWorkspaceId,
userMessage,
messages: thread.messages,
fileIds,
recordIdsByObjectMetadataNameSingular,
});
let aiResponse = '';

View File

@ -12,4 +12,5 @@ export enum AgentExceptionCode {
AGENT_EXECUTION_FAILED = 'AGENT_EXECUTION_FAILED',
API_KEY_NOT_CONFIGURED = 'API_KEY_NOT_CONFIGURED',
USER_WORKSPACE_ID_NOT_FOUND = 'USER_WORKSPACE_ID_NOT_FOUND',
ROLE_NOT_FOUND = 'ROLE_NOT_FOUND',
}

View File

@ -0,0 +1,4 @@
export type RecordIdsByObjectMetadataNameSingularType = Array<{
objectMetadataNameSingular: string;
recordIds: string[];
}>;

View File

@ -1,38 +1,114 @@
import { AvatarChipsLeftComponent } from '@ui/components/avatar-chip/AvatarChipLeftComponent';
import { AvatarChipsCommonProps } from '@ui/components/avatar-chip/types/AvatarChipsCommonProps.type';
import { Chip, ChipVariant } from '@ui/components/chip/Chip';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { Avatar } from '@ui/display/avatar/components/Avatar';
import { AvatarType } from '@ui/display/avatar/types/AvatarType';
import { IconComponent } from '@ui/display/icon/types/IconComponent';
import { Nullable } from '@ui/utilities';
import { isDefined } from 'twenty-shared/utils';
const StyledIconWithBackgroundContainer = styled.div<{
backgroundColor: string;
}>`
display: flex;
align-items: center;
justify-content: center;
width: 14px;
height: 14px;
border-radius: 4px;
background-color: ${({ backgroundColor }) => backgroundColor};
`;
const StyledAvatarChipWrapper = styled.div<{
isClickable: boolean;
divider: AvatarChipProps['divider'];
theme: any;
}>`
${({ divider, theme }) => {
const borderStyle = (side: 'left' | 'right') =>
`border-${side}: 1px solid ${theme.border.color.light};`;
return divider ? borderStyle(divider) : '';
}}
cursor: ${({ isClickable }) => (isClickable ? 'pointer' : 'inherit')};
display: flex;
`;
export type AvatarChipProps = {
placeholder?: string;
avatarUrl?: string;
avatarType?: Nullable<AvatarType>;
Icon?: IconComponent;
IconColor?: string;
IconBackgroundColor?: string;
isIconInverted?: boolean;
placeholderColorSeed?: string;
divider?: 'right' | 'left';
onClick?: () => void;
};
export type AvatarChipProps = AvatarChipsCommonProps;
export const AvatarChip = ({
name,
LeftIcon,
LeftIconColor,
Icon,
placeholderColorSeed,
avatarType,
avatarUrl,
className,
isIconInverted,
maxWidth,
placeholderColorSeed,
size,
variant = ChipVariant.Transparent,
}: AvatarChipProps) => (
<Chip
label={name}
variant={variant}
size={size}
leftComponent={
<AvatarChipsLeftComponent
name={name}
LeftIcon={LeftIcon}
LeftIconColor={LeftIconColor}
avatarType={avatarType}
placeholder,
isIconInverted = false,
IconColor,
IconBackgroundColor,
onClick,
divider,
}: AvatarChipProps) => {
const theme = useTheme();
if (!isDefined(Icon)) {
return (
<Avatar
avatarUrl={avatarUrl}
isIconInverted={isIconInverted}
placeholderColorSeed={placeholderColorSeed}
placeholder={placeholder}
size="sm"
type={avatarType}
onClick={onClick}
/>
}
clickable={false}
className={className}
maxWidth={maxWidth}
/>
);
);
}
const isClickable = isDefined(onClick);
if (isIconInverted || isDefined(IconBackgroundColor)) {
return (
<StyledAvatarChipWrapper
isClickable={isClickable}
divider={divider}
theme={theme}
onClick={onClick}
>
<StyledIconWithBackgroundContainer
backgroundColor={
IconBackgroundColor ?? theme.background.invertedSecondary
}
>
<Icon
color={theme.font.color.inverted}
size={theme.icon.size.sm}
stroke={theme.icon.stroke.sm}
/>
</StyledIconWithBackgroundContainer>
</StyledAvatarChipWrapper>
);
}
return (
<StyledAvatarChipWrapper
isClickable={isClickable}
divider={divider}
theme={theme}
onClick={onClick}
>
<Icon
size={theme.icon.size.sm}
stroke={theme.icon.stroke.sm}
color={IconColor || 'currentColor'}
/>
</StyledAvatarChipWrapper>
);
};

View File

@ -1,74 +0,0 @@
import { useTheme } from '@emotion/react';
import { styled } from '@linaria/react';
import { Avatar } from '@ui/display/avatar/components/Avatar';
import { AvatarType } from '@ui/display/avatar/types/AvatarType';
import { IconComponent } from '@ui/display/icon/types/IconComponent';
import { Nullable } from '@ui/utilities';
import { isDefined } from 'twenty-shared/utils';
const StyledInvertedIconContainer = styled.div<{ backgroundColor: string }>`
display: flex;
align-items: center;
justify-content: center;
width: 14px;
height: 14px;
border-radius: 4px;
background-color: ${({ backgroundColor }) => backgroundColor};
`;
export type AvatarChipsLeftComponentProps = {
name: string;
avatarUrl?: string;
avatarType?: Nullable<AvatarType>;
LeftIcon?: IconComponent;
LeftIconColor?: string;
isIconInverted?: boolean;
placeholderColorSeed?: string;
};
export const AvatarChipsLeftComponent: React.FC<
AvatarChipsLeftComponentProps
> = ({
LeftIcon,
placeholderColorSeed,
avatarType,
avatarUrl,
name,
isIconInverted = false,
LeftIconColor,
}) => {
const theme = useTheme();
if (!isDefined(LeftIcon)) {
return (
<Avatar
avatarUrl={avatarUrl}
placeholderColorSeed={placeholderColorSeed}
placeholder={name}
size="sm"
type={avatarType}
/>
);
}
if (isIconInverted) {
return (
<StyledInvertedIconContainer
backgroundColor={theme.background.invertedSecondary}
>
<LeftIcon
color={theme.font.color.inverted}
size={theme.icon.size.sm}
stroke={theme.icon.stroke.sm}
/>
</StyledInvertedIconContainer>
);
}
return (
<LeftIcon
size={theme.icon.size.sm}
stroke={theme.icon.stroke.sm}
color={LeftIconColor || 'currentColor'}
/>
);
};

View File

@ -1,64 +0,0 @@
import { AvatarChipsLeftComponent } from '@ui/components/avatar-chip/AvatarChipLeftComponent';
import { AvatarChipsCommonProps } from '@ui/components/avatar-chip/types/AvatarChipsCommonProps.type';
import { AvatarChipVariant } from '@ui/components/avatar-chip/types/AvatarChipsVariant.type';
import { ChipVariant } from '@ui/components/chip/Chip';
import { LinkChip, LinkChipProps } from '@ui/components/chip/LinkChip';
import { TriggerEventType } from '@ui/utilities';
export type LinkAvatarChipProps = Omit<
AvatarChipsCommonProps,
'clickable' | 'variant'
> & {
to: string;
onClick?: LinkChipProps['onClick'];
onMouseDown?: LinkChipProps['onMouseDown'];
variant?: AvatarChipVariant;
isLabelHidden?: boolean;
triggerEvent?: TriggerEventType;
};
export const LinkAvatarChip = ({
to,
onClick,
name,
LeftIcon,
LeftIconColor,
avatarType,
avatarUrl,
className,
isIconInverted,
maxWidth,
placeholderColorSeed,
size,
variant,
isLabelHidden,
triggerEvent,
}: LinkAvatarChipProps) => (
<LinkChip
to={to}
onClick={onClick}
label={name}
isLabelHidden={isLabelHidden}
variant={
//Regular but Highlighted -> missleading
variant === AvatarChipVariant.Regular
? ChipVariant.Highlighted
: ChipVariant.Regular
}
size={size}
leftComponent={
<AvatarChipsLeftComponent
name={name}
LeftIcon={LeftIcon}
LeftIconColor={LeftIconColor}
avatarType={avatarType}
avatarUrl={avatarUrl}
isIconInverted={isIconInverted}
placeholderColorSeed={placeholderColorSeed}
/>
}
className={className}
maxWidth={maxWidth}
triggerEvent={triggerEvent}
/>
);

View File

@ -0,0 +1,61 @@
import { Fragment } from 'react/jsx-runtime';
import styled from '@emotion/styled';
import { isNonEmptyString } from '@sniptt/guards';
import { isDefined } from 'twenty-shared/utils';
import { Chip, ChipVariant } from '@ui/components/chip/Chip';
const StyledIconsContainer = styled.div`
align-items: center;
display: flex;
`;
const StyledChipContainer = styled.div`
display: inline-flex;
font-size: ${({ theme }) => theme.font.size.sm};
`;
export type MultipleAvatarChipProps = {
Icons: React.ReactNode[];
text?: string;
onClick?: () => void;
testId?: string;
maxWidth?: number;
forceEmptyText?: boolean;
variant?: ChipVariant;
rightComponent?: React.ReactNode;
};
export const MultipleAvatarChip = ({
Icons,
text,
onClick,
testId,
maxWidth,
rightComponent,
variant = ChipVariant.Static,
forceEmptyText = false,
}: MultipleAvatarChipProps) => {
const leftComponent = (
<StyledIconsContainer>
{Icons.map((Icon, index) => (
<Fragment key={index}>{Icon}</Fragment>
))}
</StyledIconsContainer>
);
return (
<StyledChipContainer onClick={onClick} data-testid={testId}>
<Chip
label={text || ''}
forceEmptyText={forceEmptyText}
isLabelHidden={!isNonEmptyString(text) && forceEmptyText}
variant={variant}
leftComponent={leftComponent}
rightComponent={rightComponent}
clickable={isDefined(onClick)}
maxWidth={maxWidth}
/>
</StyledChipContainer>
);
};

View File

@ -0,0 +1,79 @@
import { Meta, StoryObj } from '@storybook/react';
import { ComponentDecorator } from '@ui/testing';
import { IconBuildingSkyscraper, IconUser } from '@ui/display';
import { AvatarChip } from '../AvatarChip';
const meta: Meta<typeof AvatarChip> = {
title: 'UI/Components/AvatarChip',
component: AvatarChip,
decorators: [ComponentDecorator],
};
export default meta;
type Story = StoryObj<typeof AvatarChip>;
export const Default: Story = {
args: {
placeholder: 'JD',
placeholderColorSeed: 'John Doe',
},
};
export const WithAvatar: Story = {
args: {
avatarUrl: 'https://i.pravatar.cc/300',
placeholder: 'JD',
placeholderColorSeed: 'John Doe',
},
};
export const WithIcon: Story = {
args: {
Icon: IconUser,
},
};
export const WithIconBackground: Story = {
args: {
Icon: IconBuildingSkyscraper,
isIconInverted: true,
},
};
export const WithInvertedIcon: Story = {
args: {
Icon: IconUser,
isIconInverted: true,
},
};
export const WithRightDivider: Story = {
args: {
placeholder: 'JD',
placeholderColorSeed: 'John Doe',
divider: 'right',
},
};
export const WithLeftDivider: Story = {
args: {
Icon: IconUser,
divider: 'left',
},
};
export const Clickable: Story = {
args: {
placeholder: 'JD',
placeholderColorSeed: 'John Doe',
onClick: () => alert('AvatarChip clicked'),
},
};
export const ClickableIcon: Story = {
args: {
Icon: IconBuildingSkyscraper,
isIconInverted: true,
onClick: () => alert('Icon AvatarChip clicked'),
},
};

View File

@ -1,16 +1,16 @@
import { Meta, StoryObj } from '@storybook/react';
import { CommandMenuContextChip } from '../CommandMenuContextChip';
import { ComponentDecorator } from 'twenty-ui/testing';
import { IconBuildingSkyscraper, IconUser } from 'twenty-ui/display';
import { ComponentDecorator } from '@ui/testing';
import { IconBuildingSkyscraper, IconUser } from '@ui/display';
import { MultipleAvatarChip } from '@ui/components';
const meta: Meta<typeof CommandMenuContextChip> = {
title: 'Modules/CommandMenu/CommandMenuContextChip',
component: CommandMenuContextChip,
const meta: Meta<typeof MultipleAvatarChip> = {
title: 'UI/Components/MultipleAvatarChip',
component: MultipleAvatarChip,
decorators: [ComponentDecorator],
};
export default meta;
type Story = StoryObj<typeof CommandMenuContextChip>;
type Story = StoryObj<typeof MultipleAvatarChip>;
export const SingleIcon: Story = {
args: {

View File

@ -1,9 +0,0 @@
import { AvatarChipsLeftComponentProps } from '@ui/components/avatar-chip/AvatarChipLeftComponent';
import { ChipSize, ChipVariant } from '@ui/components/chip/Chip';
export type AvatarChipsCommonProps = {
size?: ChipSize;
className?: string;
maxWidth?: number;
variant?: ChipVariant;
} & AvatarChipsLeftComponentProps;

View File

@ -1,4 +0,0 @@
export enum AvatarChipVariant {
Regular = 'regular',
Transparent = 'transparent',
}

View File

@ -32,8 +32,9 @@ export type ChipProps = {
variant?: ChipVariant;
accent?: ChipAccent;
leftComponent?: ReactNode | null;
rightComponent?: (() => ReactNode) | null;
rightComponent?: (() => ReactNode) | ReactNode | null;
className?: string;
forceEmptyText?: boolean;
};
const StyledDiv = withTheme(styled.div<{ theme: Theme }>`
@ -125,6 +126,18 @@ const StyledContainer = withTheme(styled.div<
: 'var(--chip-horizontal-padding)'};
`);
const renderRightComponent = (
rightComponent: (() => ReactNode) | ReactNode | null,
) => {
if (!rightComponent) {
return null;
}
return typeof rightComponent === 'function'
? rightComponent()
: rightComponent;
};
export const Chip = ({
size = ChipSize.Small,
label,
@ -137,6 +150,7 @@ export const Chip = ({
accent = ChipAccent.TextPrimary,
className,
maxWidth,
forceEmptyText = false,
}: ChipProps) => {
return (
<StyledContainer
@ -152,10 +166,12 @@ export const Chip = ({
{leftComponent}
{!isLabelHidden && label && label.trim() ? (
<OverflowingTextWithTooltip size={size} text={label} />
) : (
) : !forceEmptyText ? (
<StyledDiv>Untitled</StyledDiv>
) : (
''
)}
{rightComponent?.()}
{renderRightComponent(rightComponent)}
</StyledContainer>
);
};

View File

@ -1,29 +0,0 @@
import { Meta, StoryObj } from '@storybook/react';
import { AvatarChip } from '@ui/components/avatar-chip/AvatarChip';
import {
ComponentDecorator,
RecoilRootDecorator,
RouterDecorator,
} from '@ui/testing';
const meta: Meta<typeof AvatarChip> = {
title: 'UI/Display/Chip/AvatarChip',
component: AvatarChip,
decorators: [RouterDecorator, ComponentDecorator, RecoilRootDecorator],
args: {
name: 'Entity name',
avatarType: 'squared',
},
};
export default meta;
type Story = StoryObj<typeof AvatarChip>;
export const Default: Story = {};
export const Empty: Story = {
args: {
name: '',
},
};

View File

@ -9,12 +9,8 @@
export type { AvatarChipProps } from './avatar-chip/AvatarChip';
export { AvatarChip } from './avatar-chip/AvatarChip';
export type { AvatarChipsLeftComponentProps } from './avatar-chip/AvatarChipLeftComponent';
export { AvatarChipsLeftComponent } from './avatar-chip/AvatarChipLeftComponent';
export type { LinkAvatarChipProps } from './avatar-chip/LinkAvatarChip';
export { LinkAvatarChip } from './avatar-chip/LinkAvatarChip';
export type { AvatarChipsCommonProps } from './avatar-chip/types/AvatarChipsCommonProps.type';
export { AvatarChipVariant } from './avatar-chip/types/AvatarChipsVariant.type';
export type { MultipleAvatarChipProps } from './avatar-chip/MultipleAvatarChip';
export { MultipleAvatarChip } from './avatar-chip/MultipleAvatarChip';
export type { ChipProps } from './chip/Chip';
export { ChipSize, ChipAccent, ChipVariant, Chip } from './chip/Chip';
export { LINK_CHIP_CLICK_OUTSIDE_ID } from './chip/constants/LinkChipClickOutsideId';