diff --git a/packages/twenty-front/src/modules/ai/components/AiChatQuestionCard.tsx b/packages/twenty-front/src/modules/ai/components/AiChatQuestionCard.tsx index 4d1a592aee..6d1ab3e1d2 100644 --- a/packages/twenty-front/src/modules/ai/components/AiChatQuestionCard.tsx +++ b/packages/twenty-front/src/modules/ai/components/AiChatQuestionCard.tsx @@ -29,7 +29,7 @@ import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants'; import { AgentChatFileUploadButton } from '@/ai/components/internal/AgentChatFileUploadButton'; import { AiChatContextUsageButton } from '@/ai/components/internal/AiChatContextUsageButton'; -import { TextWithRecordLinks } from '@/ai/components/TextWithRecordLinks'; +import { TextWithChatReferences } from '@/ai/components/TextWithChatReferences'; import { useAgentChatModelId } from '@/ai/hooks/useAgentChatModelId'; import { useAiModelOptions } from '@/ai/hooks/useAiModelOptions'; import { useSubmitQuestionAnswer } from '@/ai/hooks/useSubmitQuestionAnswer'; @@ -347,7 +347,7 @@ export const AiChatQuestionCard = ({ - + {hasMultipleQuestions && ( @@ -414,7 +414,7 @@ export const AiChatQuestionCard = ({ color={themeCssVariables.font.color.tertiary} /> - + {option.isRecommended === true && ( ยท {t`Recommended`} diff --git a/packages/twenty-front/src/modules/ai/components/ChatReferenceChip.tsx b/packages/twenty-front/src/modules/ai/components/ChatReferenceChip.tsx new file mode 100644 index 0000000000..81573032b1 --- /dev/null +++ b/packages/twenty-front/src/modules/ai/components/ChatReferenceChip.tsx @@ -0,0 +1,46 @@ +import { FieldMetadataLink } from '@/ai/components/FieldMetadataLink'; +import { ObjectMetadataLink } from '@/ai/components/ObjectMetadataLink'; +import { RecordLink } from '@/ai/components/RecordLink'; +import { ViewLink } from '@/ai/components/ViewLink'; +import { type ChatReferenceMatch } from '@/ai/types/ChatReferenceMatch'; +import { assertUnreachable } from 'twenty-shared/utils'; + +type ChatReferenceChipProps = { + reference: ChatReferenceMatch; +}; + +export const ChatReferenceChip = ({ reference }: ChatReferenceChipProps) => { + switch (reference.kind) { + case 'record': + return ( + + ); + case 'object': + return ( + + ); + case 'field': + return ( + + ); + case 'view': + return ( + + ); + default: + return assertUnreachable(reference); + } +}; diff --git a/packages/twenty-front/src/modules/ai/components/ChatReferenceChipDisplay.tsx b/packages/twenty-front/src/modules/ai/components/ChatReferenceChipDisplay.tsx new file mode 100644 index 0000000000..9d8a25046c --- /dev/null +++ b/packages/twenty-front/src/modules/ai/components/ChatReferenceChipDisplay.tsx @@ -0,0 +1,37 @@ +import { t } from '@lingui/core/macro'; +import { type ReactNode } from 'react'; +import { isDefined } from 'twenty-shared/utils'; +import { Chip, ChipVariant, LinkChip } from 'twenty-ui/data-display'; + +type ChatReferenceChipDisplayProps = { + displayName: string; + leftComponent: ReactNode; + to?: string; +}; + +export const ChatReferenceChipDisplay = ({ + displayName, + leftComponent, + to, +}: ChatReferenceChipDisplayProps) => { + if (!isDefined(to)) { + return ( + + ); + } + + return ( + + ); +}; diff --git a/packages/twenty-front/src/modules/ai/components/FieldMetadataLink.tsx b/packages/twenty-front/src/modules/ai/components/FieldMetadataLink.tsx new file mode 100644 index 0000000000..7074e2caeb --- /dev/null +++ b/packages/twenty-front/src/modules/ai/components/FieldMetadataLink.tsx @@ -0,0 +1,57 @@ +import { ChatReferenceChipDisplay } from '@/ai/components/ChatReferenceChipDisplay'; +import { fieldMetadataItemByIdSelector } from '@/object-metadata/states/fieldMetadataItemByIdSelector'; +import { useHasPermissionFlag } from '@/settings/roles/hooks/useHasPermissionFlag'; +import { useAtomFamilySelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomFamilySelectorValue'; +import { SettingsPath } from 'twenty-shared/types'; +import { getSettingsPath, isDefined } from 'twenty-shared/utils'; +import { useIcons } from 'twenty-ui/icon'; +import { useTheme } from 'twenty-ui/theme-constants'; +import { PermissionFlagType } from '~/generated-metadata/graphql'; + +type FieldMetadataLinkProps = { + fieldMetadataItemId: string; + displayName: string; +}; + +export const FieldMetadataLink = ({ + fieldMetadataItemId, + displayName, +}: FieldMetadataLinkProps) => { + const theme = useTheme(); + const { getIcon } = useIcons(); + + const { foundFieldMetadataItem, foundObjectMetadataItem } = + useAtomFamilySelectorValue(fieldMetadataItemByIdSelector, { + fieldMetadataItemId, + }); + + const hasDataModelPermission = useHasPermissionFlag( + PermissionFlagType.DATA_MODEL, + ); + + if ( + !isDefined(foundFieldMetadataItem) || + !isDefined(foundObjectMetadataItem) + ) { + return {displayName}; + } + + const Icon = getIcon(foundFieldMetadataItem.icon); + + return ( + + } + /> + ); +}; diff --git a/packages/twenty-front/src/modules/ai/components/LazyMarkdownRenderer.tsx b/packages/twenty-front/src/modules/ai/components/LazyMarkdownRenderer.tsx index 9c9036b0d5..c4b49e51f2 100644 --- a/packages/twenty-front/src/modules/ai/components/LazyMarkdownRenderer.tsx +++ b/packages/twenty-front/src/modules/ai/components/LazyMarkdownRenderer.tsx @@ -6,8 +6,8 @@ import { StyledTableScrollContainer, } from '@/ai/components/LazyMarkdownRendererStyledComponents'; import { MarkdownCodeBlock } from '@/ai/components/MarkdownCodeBlock'; -import { TextWithRecordLinks } from '@/ai/components/TextWithRecordLinks'; -import { protectRecordReferencesForMarkdown } from '@/ai/utils/protectRecordReferencesForMarkdown'; +import { TextWithChatReferences } from '@/ai/components/TextWithChatReferences'; +import { protectChatReferencesForMarkdown } from '@/ai/utils/protectChatReferencesForMarkdown'; import { marked } from 'marked'; import { cloneElement, @@ -22,16 +22,16 @@ import Skeleton, { SkeletonTheme } from 'react-loading-skeleton'; import { getSafeUrl, isDefined } from 'twenty-shared/utils'; import { ThemeContext } from 'twenty-ui/theme-constants'; -const processChildrenForRecordLinks = ( +const processChildrenForChatReferences = ( children: React.ReactNode, ): React.ReactNode => { if (typeof children === 'string') { - return ; + return ; } if (Array.isArray(children)) { return children.map((child, index) => ( - {processChildrenForRecordLinks(child)} + {processChildrenForChatReferences(child)} )); } @@ -40,7 +40,7 @@ const processChildrenForRecordLinks = ( if (isDefined(childProps.children)) { return cloneElement(children, { - children: processChildrenForRecordLinks(childProps.children), + children: processChildrenForChatReferences(childProps.children), }); } } @@ -48,85 +48,88 @@ const processChildrenForRecordLinks = ( return children; }; +// react-markdown uses each entry as the JSX element type, so rebuilding this map +// per render would remount every node on every streamed chunk. +const MARKDOWN_COMPONENTS = { + table: ({ children }: { children?: React.ReactNode }) => ( + + {children}
+
+ ), + p: ({ children }: { children?: React.ReactNode }) => ( + + {processChildrenForChatReferences(children)} + + ), + td: ({ children }: { children?: React.ReactNode }) => ( + {processChildrenForChatReferences(children)} + ), + th: ({ children }: { children?: React.ReactNode }) => ( + {processChildrenForChatReferences(children)} + ), + li: ({ children }: { children?: React.ReactNode }) => ( +
  • {processChildrenForChatReferences(children)}
  • + ), + h1: ({ children }: { children?: React.ReactNode }) => ( +

    {processChildrenForChatReferences(children)}

    + ), + h2: ({ children }: { children?: React.ReactNode }) => ( +

    {processChildrenForChatReferences(children)}

    + ), + h3: ({ children }: { children?: React.ReactNode }) => ( +

    {processChildrenForChatReferences(children)}

    + ), + h4: ({ children }: { children?: React.ReactNode }) => ( +

    {processChildrenForChatReferences(children)}

    + ), + h5: ({ children }: { children?: React.ReactNode }) => ( +
    {processChildrenForChatReferences(children)}
    + ), + h6: ({ children }: { children?: React.ReactNode }) => ( +
    {processChildrenForChatReferences(children)}
    + ), + a: ({ + children, + href, + title, + }: { + children?: React.ReactNode; + href?: string; + title?: string; + }) => ( + + {processChildrenForChatReferences(children)} + + ), + code: ({ + className, + children, + }: { + className?: string; + children?: React.ReactNode; + }) => {children}, + pre: ({ children }: { children?: React.ReactNode }) => ( + {children} + ), +}; + const MarkdownRenderer = lazy(async () => { const [{ default: Markdown }, { default: remarkGfm }] = await Promise.all([ import('react-markdown'), import('remark-gfm'), ]); + const remarkPlugins = [remarkGfm]; + return { - default: ({ - children, - TableScrollContainer, - ParagraphComponent, - }: { - children: string; - TableScrollContainer: React.ComponentType<{ children: React.ReactNode }>; - ParagraphComponent: React.ComponentType<{ children: React.ReactNode }>; - }) => ( - ( - - {children}
    -
    - ), - p: ({ children }) => ( - - {processChildrenForRecordLinks(children)} - - ), - td: ({ children }) => ( - {processChildrenForRecordLinks(children)} - ), - th: ({ children }) => ( - {processChildrenForRecordLinks(children)} - ), - li: ({ children }) => ( -
  • {processChildrenForRecordLinks(children)}
  • - ), - h1: ({ children }) => ( -

    {processChildrenForRecordLinks(children)}

    - ), - h2: ({ children }) => ( -

    {processChildrenForRecordLinks(children)}

    - ), - h3: ({ children }) => ( -

    {processChildrenForRecordLinks(children)}

    - ), - h4: ({ children }) => ( -

    {processChildrenForRecordLinks(children)}

    - ), - h5: ({ children }) => ( -
    {processChildrenForRecordLinks(children)}
    - ), - h6: ({ children }) => ( -
    {processChildrenForRecordLinks(children)}
    - ), - a: ({ children, href, title, node: _node }) => ( - - {processChildrenForRecordLinks(children)} - - ), - code: ({ - className, - children, - }: { - className?: string; - children?: React.ReactNode; - }) => {children}, - pre: ({ children }) => ( - {children} - ), - }} - > + default: ({ children }: { children: string }) => ( + {children} ), @@ -169,19 +172,14 @@ const LoadingSkeleton = () => { const MemoizedMarkdownBlock = memo( ({ blockText }: { blockText: string }) => ( - - {blockText} - + {blockText} ), (previousProps, nextProps) => previousProps.blockText === nextProps.blockText, ); export const LazyMarkdownRenderer = ({ text }: { text: string }) => { const protectedText = useMemo( - () => protectRecordReferencesForMarkdown(text), + () => protectChatReferencesForMarkdown(text), [text], ); diff --git a/packages/twenty-front/src/modules/ai/components/ObjectMetadataLink.tsx b/packages/twenty-front/src/modules/ai/components/ObjectMetadataLink.tsx new file mode 100644 index 0000000000..88fd536e27 --- /dev/null +++ b/packages/twenty-front/src/modules/ai/components/ObjectMetadataLink.tsx @@ -0,0 +1,56 @@ +import { ChatReferenceChipDisplay } from '@/ai/components/ChatReferenceChipDisplay'; +import { ObjectMetadataIcon } from '@/object-metadata/components/ObjectMetadataIcon'; +import { objectMetadataItemFamilySelector } from '@/object-metadata/states/objectMetadataItemFamilySelector'; +import { useAtomFamilySelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomFamilySelectorValue'; +import { AppPath } from 'twenty-shared/types'; +import { getAppPath, isDefined } from 'twenty-shared/utils'; +import { useTheme } from 'twenty-ui/theme-constants'; + +const PROPOSED_OBJECT_METADATA_ICON = 'IconListNumbers'; + +type ObjectMetadataLinkProps = { + objectNameSingular: string; + displayName: string; +}; + +export const ObjectMetadataLink = ({ + objectNameSingular, + displayName, +}: ObjectMetadataLinkProps) => { + const theme = useTheme(); + + const objectMetadataItem = useAtomFamilySelectorValue( + objectMetadataItemFamilySelector, + { + objectName: objectNameSingular, + objectNameType: 'singular', + }, + ); + + return ( + + } + /> + ); +}; diff --git a/packages/twenty-front/src/modules/ai/components/RecordLink.tsx b/packages/twenty-front/src/modules/ai/components/RecordLink.tsx index eb66599bb9..a732c51546 100644 --- a/packages/twenty-front/src/modules/ai/components/RecordLink.tsx +++ b/packages/twenty-front/src/modules/ai/components/RecordLink.tsx @@ -1,9 +1,9 @@ +import { ChatReferenceChipDisplay } from '@/ai/components/ChatReferenceChipDisplay'; 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/data-display'; +import { AvatarOrIcon } from 'twenty-ui/data-display'; type RecordLinkProps = { objectNameSingular: string; @@ -28,16 +28,10 @@ export const RecordLink = ({ return {displayName}; } - const linkToShowPage = getLinkToShowPage(objectNameSingular, { - id: recordId, - }); - return ( - { - const references = findRecordReferences(text); +export const TextWithChatReferences = ({ + text, +}: TextWithChatReferencesProps) => { + const references = findChatReferences(text); if (references.length === 0) { return <>{text}; @@ -22,12 +24,7 @@ export const TextWithRecordLinks = ({ text }: TextWithRecordLinksProps) => { } parts.push( - , + , ); lastIndex = reference.index + reference.fullMatch.length; diff --git a/packages/twenty-front/src/modules/ai/components/ViewLink.tsx b/packages/twenty-front/src/modules/ai/components/ViewLink.tsx new file mode 100644 index 0000000000..bdb812b7d0 --- /dev/null +++ b/packages/twenty-front/src/modules/ai/components/ViewLink.tsx @@ -0,0 +1,47 @@ +import { ChatReferenceChipDisplay } from '@/ai/components/ChatReferenceChipDisplay'; +import { flatObjectMetadataItemsSelector } from '@/object-metadata/states/flatObjectMetadataItemsSelector'; +import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue'; +import { useViewById } from '@/views/hooks/useViewById'; +import { AppPath } from 'twenty-shared/types'; +import { findById, getAppPath, isDefined } from 'twenty-shared/utils'; +import { useIcons } from 'twenty-ui/icon'; +import { useTheme } from 'twenty-ui/theme-constants'; + +type ViewLinkProps = { + viewId: string; + displayName: string; +}; + +export const ViewLink = ({ viewId, displayName }: ViewLinkProps) => { + const theme = useTheme(); + const { getIcon } = useIcons(); + + const { view } = useViewById(viewId); + const flatObjectMetadataItems = useAtomStateValue( + flatObjectMetadataItemsSelector, + ); + + const objectMetadataItem = isDefined(view) + ? flatObjectMetadataItems.find(findById(view.objectMetadataId)) + : undefined; + + if (!isDefined(view) || !isDefined(objectMetadataItem)) { + return {displayName}; + } + + const Icon = getIcon(view.icon); + + return ( + + } + /> + ); +}; diff --git a/packages/twenty-front/src/modules/ai/components/__stories__/ChatReferenceChip.stories.tsx b/packages/twenty-front/src/modules/ai/components/__stories__/ChatReferenceChip.stories.tsx new file mode 100644 index 0000000000..63d878b4a6 --- /dev/null +++ b/packages/twenty-front/src/modules/ai/components/__stories__/ChatReferenceChip.stories.tsx @@ -0,0 +1,134 @@ +import { type Meta, type StoryObj } from '@storybook/react-vite'; +import { expect, within } from 'storybook/test'; + +import { LazyMarkdownRenderer } from '@/ai/components/LazyMarkdownRenderer'; +import { formatChatReference } from '@/ai/utils/formatChatReference'; +import { currentUserWorkspaceState } from '@/auth/states/currentUserWorkspaceState'; +import { styled } from '@linaria/react'; +import { useStore } from 'jotai'; +import { type ReactNode, useEffect, useState } from 'react'; +import { type ViewWithRelations } from '@/views/types/ViewWithRelations'; +import { PermissionFlagType } from '~/generated-metadata/graphql'; +import { ComponentWithRouterDecorator } from '~/testing/decorators/ComponentWithRouterDecorator'; +import { IconsProviderDecorator } from '~/testing/decorators/IconsProviderDecorator'; +import { ObjectMetadataItemsDecorator } from '~/testing/decorators/ObjectMetadataItemsDecorator'; +import { getMockFieldMetadataItemOrThrow } from '~/testing/utils/getMockFieldMetadataItemOrThrow'; +import { getMockObjectMetadataItemOrThrow } from '~/testing/utils/getMockObjectMetadataItemOrThrow'; +import { setTestViewsInMetadataStore } from '~/testing/utils/setTestViewsInMetadataStore'; + +const StyledContainer = styled.div` + max-width: 640px; +`; + +const companyObjectMetadataItem = getMockObjectMetadataItemOrThrow('company'); + +const employeesFieldMetadataItem = getMockFieldMetadataItemOrThrow({ + objectMetadataItem: companyObjectMetadataItem, + fieldName: 'employees', +}); + +const allCompaniesView = { + id: '20202020-4444-4444-4444-444444444444', + name: 'All Companies', + icon: 'IconList', + objectMetadataId: companyObjectMetadataItem.id, + isActive: true, +} as ViewWithRelations; + +// Declared locally because ObjectMetadataItemsDecorator drops every mocked view, +// and importing the generated view mocks breaks the linaria build-time evaluator. +const ChatReferenceStoreSeeder = ({ children }: { children: ReactNode }) => { + const store = useStore(); + const [isSeeded, setIsSeeded] = useState(false); + + useEffect(() => { + setTestViewsInMetadataStore(store, [allCompaniesView]); + + store.set(currentUserWorkspaceState.atom, { + permissionFlags: [PermissionFlagType.DATA_MODEL], + twoFactorAuthenticationMethodSummary: [], + objectsPermissions: [], + }); + + setIsSeeded(true); + }, [store]); + + return isSeeded ? <>{children} : null; +}; + +const meta: Meta = { + title: 'Modules/AiChat/ChatReferenceChip', + component: LazyMarkdownRenderer, + decorators: [ + (Story) => ( + + + + + + ), + ObjectMetadataItemsDecorator, + IconsProviderDecorator, + ComponentWithRouterDecorator, + ], +}; + +export default meta; + +type Story = StoryObj; + +export const ExistingMetadata: Story = { + args: { + text: `Your ${formatChatReference({ + kind: 'object', + objectNameSingular: 'company', + displayName: 'Companies', + })} object is sorted by ${formatChatReference({ + kind: 'field', + fieldMetadataItemId: employeesFieldMetadataItem.id, + displayName: 'Employees', + })} in the ${formatChatReference({ + kind: 'view', + viewId: allCompaniesView.id, + displayName: allCompaniesView.name, + })} view.`, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + expect((await canvas.findByText('Companies')).closest('a')).toHaveAttribute( + 'href', + `/objects/${companyObjectMetadataItem.namePlural}`, + ); + expect((await canvas.findByText('Employees')).closest('a')).toHaveAttribute( + 'href', + `/settings/objects/${companyObjectMetadataItem.namePlural}/${employeesFieldMetadataItem.name}`, + ); + expect( + (await canvas.findByText('All Companies')).closest('a'), + ).toHaveAttribute( + 'href', + `/objects/${companyObjectMetadataItem.namePlural}?viewId=${allCompaniesView.id}`, + ); + }, +}; + +export const ProposedObject: Story = { + args: { + text: `As a Head of Partnerships, you seem to work across partner companies, key contacts, and commercial follow-ups, so I suggest creating a ${formatChatReference( + { + kind: 'object', + objectNameSingular: 'partner', + displayName: 'Partners', + }, + )} object to track relationship status, partner type, owner, and next step. Should I create it?`, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + const proposedObjectChipLabel = await canvas.findByText('Partners'); + + expect(proposedObjectChipLabel).toBeVisible(); + expect(proposedObjectChipLabel.closest('a')).toBeNull(); + }, +}; diff --git a/packages/twenty-front/src/modules/ai/components/__tests__/FieldMetadataLink.test.tsx b/packages/twenty-front/src/modules/ai/components/__tests__/FieldMetadataLink.test.tsx new file mode 100644 index 0000000000..b84e7439a7 --- /dev/null +++ b/packages/twenty-front/src/modules/ai/components/__tests__/FieldMetadataLink.test.tsx @@ -0,0 +1,91 @@ +import { render, screen } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; + +import { FieldMetadataLink } from '@/ai/components/FieldMetadataLink'; +import { currentUserWorkspaceState } from '@/auth/states/currentUserWorkspaceState'; +import { type Store } from 'jotai/vanilla/store'; +import { PermissionFlagType } from '~/generated-metadata/graphql'; +import { getJestMetadataAndApolloMocksWrapper } from '~/testing/jest/getJestMetadataAndApolloMocksWrapper'; +import { getMockFieldMetadataItemOrThrow } from '~/testing/utils/getMockFieldMetadataItemOrThrow'; +import { getMockObjectMetadataItemOrThrow } from '~/testing/utils/getMockObjectMetadataItemOrThrow'; + +const companyObjectMetadataItem = getMockObjectMetadataItemOrThrow('company'); + +const nameFieldMetadataItem = getMockFieldMetadataItemOrThrow({ + objectMetadataItem: companyObjectMetadataItem, + fieldName: 'name', +}); + +const setPermissionFlags = ( + store: Store, + permissionFlags: PermissionFlagType[], +) => { + store.set(currentUserWorkspaceState.atom, { + permissionFlags, + twoFactorAuthenticationMethodSummary: [], + objectsPermissions: [], + }); +}; + +const renderFieldMetadataLink = ({ + fieldMetadataItemId, + displayName, + permissionFlags, +}: { + fieldMetadataItemId: string; + displayName: string; + permissionFlags: PermissionFlagType[]; +}) => { + const Wrapper = getJestMetadataAndApolloMocksWrapper({ + apolloMocks: [], + onInitializeJotaiStore: (store) => + setPermissionFlags(store, permissionFlags), + }); + + return render( + + + , + { wrapper: Wrapper }, + ); +}; + +describe('FieldMetadataLink', () => { + it('should link a field to its settings page', () => { + renderFieldMetadataLink({ + fieldMetadataItemId: nameFieldMetadataItem.id, + displayName: 'Name', + permissionFlags: [PermissionFlagType.DATA_MODEL], + }); + + expect(screen.getByText('Name').closest('a')).toHaveAttribute( + 'href', + `/settings/objects/${companyObjectMetadataItem.namePlural}/${nameFieldMetadataItem.name}`, + ); + }); + + it('should render a chip without a link when the user cannot access the data model', () => { + renderFieldMetadataLink({ + fieldMetadataItemId: nameFieldMetadataItem.id, + displayName: 'Name', + permissionFlags: [], + }); + + expect(screen.getByText('Name').closest('a')).toBeNull(); + expect(screen.getByTestId('chip')).toBeInTheDocument(); + }); + + it('should render plain text for an unknown field id', () => { + renderFieldMetadataLink({ + fieldMetadataItemId: '33333333-3333-3333-3333-333333333333', + displayName: 'Partner type', + permissionFlags: [PermissionFlagType.DATA_MODEL], + }); + + expect(screen.getByText('Partner type')).toBeInTheDocument(); + expect(screen.queryByTestId('chip')).not.toBeInTheDocument(); + }); +}); diff --git a/packages/twenty-front/src/modules/ai/components/__tests__/ObjectMetadataLink.test.tsx b/packages/twenty-front/src/modules/ai/components/__tests__/ObjectMetadataLink.test.tsx new file mode 100644 index 0000000000..6d7ab0148f --- /dev/null +++ b/packages/twenty-front/src/modules/ai/components/__tests__/ObjectMetadataLink.test.tsx @@ -0,0 +1,49 @@ +import { render, screen } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; + +import { ObjectMetadataLink } from '@/ai/components/ObjectMetadataLink'; +import { getJestMetadataAndApolloMocksWrapper } from '~/testing/jest/getJestMetadataAndApolloMocksWrapper'; + +const Wrapper = getJestMetadataAndApolloMocksWrapper({ apolloMocks: [] }); + +const renderObjectMetadataLink = ({ + objectNameSingular, + displayName, +}: { + objectNameSingular: string; + displayName: string; +}) => + render( + + + , + { wrapper: Wrapper }, + ); + +describe('ObjectMetadataLink', () => { + it('should link an existing object to its record index page', () => { + renderObjectMetadataLink({ + objectNameSingular: 'company', + displayName: 'Companies', + }); + + expect(screen.getByText('Companies').closest('a')).toHaveAttribute( + 'href', + '/objects/companies', + ); + }); + + it('should render a chip without a link for an object that does not exist yet', () => { + renderObjectMetadataLink({ + objectNameSingular: 'partner', + displayName: 'Partners', + }); + + expect(screen.getByText('Partners')).toBeInTheDocument(); + expect(screen.getByText('Partners').closest('a')).toBeNull(); + expect(screen.getByTestId('chip')).toBeInTheDocument(); + }); +}); diff --git a/packages/twenty-front/src/modules/ai/components/__tests__/TextWithChatReferences.test.tsx b/packages/twenty-front/src/modules/ai/components/__tests__/TextWithChatReferences.test.tsx new file mode 100644 index 0000000000..1a5c753740 --- /dev/null +++ b/packages/twenty-front/src/modules/ai/components/__tests__/TextWithChatReferences.test.tsx @@ -0,0 +1,138 @@ +import { render, screen } from '@testing-library/react'; + +import { TextWithChatReferences } from '@/ai/components/TextWithChatReferences'; + +jest.mock('@/ai/components/RecordLink', () => ({ + RecordLink: ({ + displayName, + objectNameSingular, + recordId, + }: { + displayName: string; + objectNameSingular: string; + recordId: string; + }) => ( + + {displayName} + + ), +})); + +jest.mock('@/ai/components/ObjectMetadataLink', () => ({ + ObjectMetadataLink: ({ + displayName, + objectNameSingular, + }: { + displayName: string; + objectNameSingular: string; + }) => ( + + {displayName} + + ), +})); + +jest.mock('@/ai/components/FieldMetadataLink', () => ({ + FieldMetadataLink: ({ + displayName, + fieldMetadataItemId, + }: { + displayName: string; + fieldMetadataItemId: string; + }) => ( + + {displayName} + + ), +})); + +jest.mock('@/ai/components/ViewLink', () => ({ + ViewLink: ({ + displayName, + viewId, + }: { + displayName: string; + viewId: string; + }) => ( + + {displayName} + + ), +})); + +describe('TextWithChatReferences', () => { + it('should render plain text without references as-is', () => { + render(); + + expect( + screen.getByText('Which company should we contact?'), + ).toBeInTheDocument(); + expect(screen.queryByTestId('record-link')).not.toBeInTheDocument(); + }); + + it('should replace tagged record references with RecordLink chips', () => { + render( + , + ); + + expect(screen.getByTestId('record-link')).toHaveTextContent('Acme'); + expect(screen.getByText(/Contact/)).toHaveTextContent('Contact Acme next'); + expect(screen.queryByText(/\[\[record:company:/)).not.toBeInTheDocument(); + }); + + it('should still replace legacy ]] record references with RecordLink chips', () => { + render( + , + ); + + expect(screen.getByTestId('record-link')).toHaveTextContent('Acme'); + expect(screen.getByText(/Contact/)).toHaveTextContent('Contact Acme next'); + }); + + it('should replace multiple record references in option-style labels', () => { + render( + , + ); + + const recordLinks = screen.getAllByTestId('record-link'); + + expect(recordLinks).toHaveLength(2); + expect(recordLinks[0]).toHaveTextContent('Alice'); + expect(recordLinks[1]).toHaveTextContent('Bob'); + expect(screen.queryByText(/\[\[/)).not.toBeInTheDocument(); + }); + + it('should chip tagged labels that contain backticks, brackets, colons, and ]]', () => { + render( + , + ); + + const recordLinks = screen.getAllByTestId('record-link'); + + expect(recordLinks).toHaveLength(3); + expect(recordLinks[0]).toHaveTextContent('Workflow `UPDATE_RECORD` step'); + expect(recordLinks[1]).toHaveTextContent('[test] ]] [test] [test] ###'); + expect(recordLinks[2]).toHaveTextContent('Doe: Jane'); + expect(screen.queryByText(/\[\[record:/)).not.toBeInTheDocument(); + }); + + it('should route each reference kind to its own chip', () => { + render( + , + ); + + expect(screen.getByTestId('view-link')).toHaveAttribute( + 'href', + '/views/44444444-4444-4444-4444-444444444444', + ); + expect(screen.getByTestId('object-link')).toHaveAttribute( + 'href', + '/objects/partner', + ); + expect(screen.getByTestId('record-link')).toHaveTextContent('Alice'); + expect(screen.getByTestId('field-link')).toHaveAttribute( + 'href', + '/fields/33333333-3333-3333-3333-333333333333', + ); + }); +}); diff --git a/packages/twenty-front/src/modules/ai/components/__tests__/TextWithRecordLinks.test.tsx b/packages/twenty-front/src/modules/ai/components/__tests__/TextWithRecordLinks.test.tsx deleted file mode 100644 index ca6a9a837c..0000000000 --- a/packages/twenty-front/src/modules/ai/components/__tests__/TextWithRecordLinks.test.tsx +++ /dev/null @@ -1,76 +0,0 @@ -import { render, screen } from '@testing-library/react'; - -import { TextWithRecordLinks } from '@/ai/components/TextWithRecordLinks'; - -jest.mock('@/ai/components/RecordLink', () => ({ - RecordLink: ({ - displayName, - objectNameSingular, - recordId, - }: { - displayName: string; - objectNameSingular: string; - recordId: string; - }) => ( - - {displayName} - - ), -})); - -describe('TextWithRecordLinks', () => { - it('should render plain text without record references as-is', () => { - render(); - - expect( - screen.getByText('Which company should we contact?'), - ).toBeInTheDocument(); - expect(screen.queryByTestId('record-link')).not.toBeInTheDocument(); - }); - - it('should replace tagged record references with RecordLink chips', () => { - render( - , - ); - - expect(screen.getByTestId('record-link')).toHaveTextContent('Acme'); - expect(screen.getByText(/Contact/)).toHaveTextContent('Contact Acme next'); - expect(screen.queryByText(/\[\[record:company:/)).not.toBeInTheDocument(); - }); - - it('should still replace legacy ]] record references with RecordLink chips', () => { - render( - , - ); - - expect(screen.getByTestId('record-link')).toHaveTextContent('Acme'); - expect(screen.getByText(/Contact/)).toHaveTextContent('Contact Acme next'); - }); - - it('should replace multiple record references in option-style labels', () => { - render( - , - ); - - const recordLinks = screen.getAllByTestId('record-link'); - - expect(recordLinks).toHaveLength(2); - expect(recordLinks[0]).toHaveTextContent('Alice'); - expect(recordLinks[1]).toHaveTextContent('Bob'); - expect(screen.queryByText(/\[\[/)).not.toBeInTheDocument(); - }); - - it('should chip tagged labels that contain backticks, brackets, colons, and ]]', () => { - render( - , - ); - - const recordLinks = screen.getAllByTestId('record-link'); - - expect(recordLinks).toHaveLength(3); - expect(recordLinks[0]).toHaveTextContent('Workflow `UPDATE_RECORD` step'); - expect(recordLinks[1]).toHaveTextContent('[test] ]] [test] [test] ###'); - expect(recordLinks[2]).toHaveTextContent('Doe: Jane'); - expect(screen.queryByText(/\[\[record:/)).not.toBeInTheDocument(); - }); -}); diff --git a/packages/twenty-front/src/modules/ai/components/__tests__/ViewLink.test.tsx b/packages/twenty-front/src/modules/ai/components/__tests__/ViewLink.test.tsx new file mode 100644 index 0000000000..1ef439254b --- /dev/null +++ b/packages/twenty-front/src/modules/ai/components/__tests__/ViewLink.test.tsx @@ -0,0 +1,79 @@ +import { render, screen } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; + +import { ViewLink } from '@/ai/components/ViewLink'; +import { type ViewWithRelations } from '@/views/types/ViewWithRelations'; +import { getJestMetadataAndApolloMocksWrapper } from '~/testing/jest/getJestMetadataAndApolloMocksWrapper'; +import { getMockObjectMetadataItemOrThrow } from '~/testing/utils/getMockObjectMetadataItemOrThrow'; +import { setTestViewsInMetadataStore } from '~/testing/utils/setTestViewsInMetadataStore'; + +const companyObjectMetadataItem = getMockObjectMetadataItemOrThrow('company'); + +const VIEW_ID = '44444444-4444-4444-4444-444444444444'; + +const allCompaniesView = { + id: VIEW_ID, + name: 'All Companies', + icon: 'IconBuildingSkyscraper', + objectMetadataId: companyObjectMetadataItem.id, + isActive: true, +} as ViewWithRelations; + +const renderViewLink = ({ + viewId, + displayName, + views, +}: { + viewId: string; + displayName: string; + views: ViewWithRelations[]; +}) => { + const Wrapper = getJestMetadataAndApolloMocksWrapper({ + apolloMocks: [], + onInitializeJotaiStore: (store) => + setTestViewsInMetadataStore(store, views), + }); + + return render( + + + , + { wrapper: Wrapper }, + ); +}; + +describe('ViewLink', () => { + it('should link a view to its object index page', () => { + renderViewLink({ + viewId: VIEW_ID, + displayName: 'All Companies', + views: [allCompaniesView], + }); + + expect(screen.getByText('All Companies').closest('a')).toHaveAttribute( + 'href', + `/objects/${companyObjectMetadataItem.namePlural}?viewId=${VIEW_ID}`, + ); + }); + + it('should render plain text for an unknown view id', () => { + renderViewLink({ + viewId: VIEW_ID, + displayName: 'All Companies', + views: [], + }); + + expect(screen.getByText('All Companies')).toBeInTheDocument(); + expect(screen.queryByTestId('chip')).not.toBeInTheDocument(); + }); + + it('should render plain text for an archived view', () => { + renderViewLink({ + viewId: VIEW_ID, + displayName: 'All Companies', + views: [{ ...allCompaniesView, isActive: false }], + }); + + expect(screen.queryByTestId('chip')).not.toBeInTheDocument(); + }); +}); diff --git a/packages/twenty-front/src/modules/ai/constants/AnyChatReferenceCloseTagRegex.ts b/packages/twenty-front/src/modules/ai/constants/AnyChatReferenceCloseTagRegex.ts new file mode 100644 index 0000000000..a3dbaba49f --- /dev/null +++ b/packages/twenty-front/src/modules/ai/constants/AnyChatReferenceCloseTagRegex.ts @@ -0,0 +1,5 @@ +import { CHAT_REFERENCE_KINDS } from '@/ai/constants/ChatReferenceKinds'; + +export const ANY_CHAT_REFERENCE_CLOSE_TAG_REGEX = new RegExp( + `\\[\\[/(?:${CHAT_REFERENCE_KINDS.join('|')})\\]\\]`, +); diff --git a/packages/twenty-front/src/modules/ai/constants/ChatReferenceKinds.ts b/packages/twenty-front/src/modules/ai/constants/ChatReferenceKinds.ts new file mode 100644 index 0000000000..82334264ec --- /dev/null +++ b/packages/twenty-front/src/modules/ai/constants/ChatReferenceKinds.ts @@ -0,0 +1,6 @@ +export const CHAT_REFERENCE_KINDS = [ + 'record', + 'object', + 'field', + 'view', +] as const; diff --git a/packages/twenty-front/src/modules/ai/constants/ChatReferenceMetadataNamePattern.ts b/packages/twenty-front/src/modules/ai/constants/ChatReferenceMetadataNamePattern.ts new file mode 100644 index 0000000000..200a0262eb --- /dev/null +++ b/packages/twenty-front/src/modules/ai/constants/ChatReferenceMetadataNamePattern.ts @@ -0,0 +1 @@ +export const CHAT_REFERENCE_METADATA_NAME_PATTERN = '[a-zA-Z][a-zA-Z0-9]*'; diff --git a/packages/twenty-front/src/modules/ai/constants/ChatReferenceStartRegex.ts b/packages/twenty-front/src/modules/ai/constants/ChatReferenceStartRegex.ts new file mode 100644 index 0000000000..ea049fc9c4 --- /dev/null +++ b/packages/twenty-front/src/modules/ai/constants/ChatReferenceStartRegex.ts @@ -0,0 +1,14 @@ +import { CHAT_REFERENCE_METADATA_NAME_PATTERN } from '@/ai/constants/ChatReferenceMetadataNamePattern'; +import { CHAT_REFERENCE_UUID_PATTERN } from '@/ai/constants/ChatReferenceUuidPattern'; + +// The record alternative must stay last: its `record:` prefix is optional, so it +// matches the metadata markers too and would swallow them if tried first. +export const CHAT_REFERENCE_START_REGEX = new RegExp( + [ + `\\[\\[object:(?${CHAT_REFERENCE_METADATA_NAME_PATTERN}):`, + `\\[\\[field:(?${CHAT_REFERENCE_UUID_PATTERN}):`, + `\\[\\[view:(?${CHAT_REFERENCE_UUID_PATTERN}):`, + `\\[\\[(?:record:)?(?${CHAT_REFERENCE_METADATA_NAME_PATTERN}):(?${CHAT_REFERENCE_UUID_PATTERN}):`, + ].join('|'), + 'g', +); diff --git a/packages/twenty-front/src/modules/ai/constants/ChatReferenceUuidPattern.ts b/packages/twenty-front/src/modules/ai/constants/ChatReferenceUuidPattern.ts new file mode 100644 index 0000000000..409c464d42 --- /dev/null +++ b/packages/twenty-front/src/modules/ai/constants/ChatReferenceUuidPattern.ts @@ -0,0 +1,2 @@ +export const CHAT_REFERENCE_UUID_PATTERN = + '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}'; diff --git a/packages/twenty-front/src/modules/ai/constants/RecordReferenceCloseTag.ts b/packages/twenty-front/src/modules/ai/constants/RecordReferenceCloseTag.ts deleted file mode 100644 index 80c87936f4..0000000000 --- a/packages/twenty-front/src/modules/ai/constants/RecordReferenceCloseTag.ts +++ /dev/null @@ -1 +0,0 @@ -export const RECORD_REFERENCE_CLOSE_TAG = '[[/record]]'; diff --git a/packages/twenty-front/src/modules/ai/types/ChatReferenceClosing.ts b/packages/twenty-front/src/modules/ai/types/ChatReferenceClosing.ts new file mode 100644 index 0000000000..a4f422612f --- /dev/null +++ b/packages/twenty-front/src/modules/ai/types/ChatReferenceClosing.ts @@ -0,0 +1,4 @@ +export type ChatReferenceClosing = { + index: number; + length: number; +}; diff --git a/packages/twenty-front/src/modules/ai/types/ChatReferenceIdentity.ts b/packages/twenty-front/src/modules/ai/types/ChatReferenceIdentity.ts new file mode 100644 index 0000000000..3b94c825c4 --- /dev/null +++ b/packages/twenty-front/src/modules/ai/types/ChatReferenceIdentity.ts @@ -0,0 +1,5 @@ +export type ChatReferenceIdentity = + | { kind: 'record'; objectNameSingular: string; recordId: string } + | { kind: 'object'; objectNameSingular: string } + | { kind: 'field'; fieldMetadataItemId: string } + | { kind: 'view'; viewId: string }; diff --git a/packages/twenty-front/src/modules/ai/types/ChatReferenceKind.ts b/packages/twenty-front/src/modules/ai/types/ChatReferenceKind.ts new file mode 100644 index 0000000000..c6ddeab25c --- /dev/null +++ b/packages/twenty-front/src/modules/ai/types/ChatReferenceKind.ts @@ -0,0 +1,3 @@ +import { type CHAT_REFERENCE_KINDS } from '@/ai/constants/ChatReferenceKinds'; + +export type ChatReferenceKind = (typeof CHAT_REFERENCE_KINDS)[number]; diff --git a/packages/twenty-front/src/modules/ai/types/ChatReferenceMatch.ts b/packages/twenty-front/src/modules/ai/types/ChatReferenceMatch.ts new file mode 100644 index 0000000000..e849c2e7a1 --- /dev/null +++ b/packages/twenty-front/src/modules/ai/types/ChatReferenceMatch.ts @@ -0,0 +1,7 @@ +import { type ChatReferenceIdentity } from '@/ai/types/ChatReferenceIdentity'; + +export type ChatReferenceMatch = ChatReferenceIdentity & { + fullMatch: string; + index: number; + displayName: string; +}; diff --git a/packages/twenty-front/src/modules/ai/types/ChatReferenceStart.ts b/packages/twenty-front/src/modules/ai/types/ChatReferenceStart.ts new file mode 100644 index 0000000000..5ff0f5295e --- /dev/null +++ b/packages/twenty-front/src/modules/ai/types/ChatReferenceStart.ts @@ -0,0 +1,7 @@ +import { type ChatReferenceIdentity } from '@/ai/types/ChatReferenceIdentity'; + +export type ChatReferenceStart = { + index: number; + prefixLength: number; + identity: ChatReferenceIdentity; +}; diff --git a/packages/twenty-front/src/modules/ai/types/RecordReferenceMatch.ts b/packages/twenty-front/src/modules/ai/types/RecordReferenceMatch.ts deleted file mode 100644 index 89ad453af3..0000000000 --- a/packages/twenty-front/src/modules/ai/types/RecordReferenceMatch.ts +++ /dev/null @@ -1,7 +0,0 @@ -export type RecordReferenceMatch = { - fullMatch: string; - index: number; - objectNameSingular: string; - recordId: string; - displayName: string; -}; diff --git a/packages/twenty-front/src/modules/ai/utils/__tests__/findChatReferences.test.ts b/packages/twenty-front/src/modules/ai/utils/__tests__/findChatReferences.test.ts new file mode 100644 index 0000000000..8bd57ad54d --- /dev/null +++ b/packages/twenty-front/src/modules/ai/utils/__tests__/findChatReferences.test.ts @@ -0,0 +1,263 @@ +import { findChatReferences } from '@/ai/utils/findChatReferences'; + +describe('findChatReferences', () => { + it('should leave plain text without matches', () => { + expect(findChatReferences('Which company should we contact?')).toEqual([]); + }); + + it('should find a tagged record reference', () => { + expect( + findChatReferences( + 'Contact [[record:company:a1b2c3d4-e5f6-7890-abcd-ef1234567890:Acme[[/record]] next', + ), + ).toEqual([ + { + kind: 'record', + fullMatch: + '[[record:company:a1b2c3d4-e5f6-7890-abcd-ef1234567890:Acme[[/record]]', + index: 8, + objectNameSingular: 'company', + recordId: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890', + displayName: 'Acme', + }, + ]); + }); + + it('should include ]] inside a tagged display name', () => { + expect( + findChatReferences( + 'The company is [[record:company:a1b2c3d4-e5f6-7890-abcd-ef1234567890:[test] ]] [test] [test] ###[[/record]], created on July 21', + ), + ).toEqual([ + { + kind: 'record', + fullMatch: + '[[record:company:a1b2c3d4-e5f6-7890-abcd-ef1234567890:[test] ]] [test] [test] ###[[/record]]', + index: 15, + objectNameSingular: 'company', + recordId: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890', + displayName: '[test] ]] [test] [test] ###', + }, + ]); + }); + + it('should still support legacy ]] terminators', () => { + expect( + findChatReferences( + 'Contact [[record:company:a1b2c3d4-e5f6-7890-abcd-ef1234567890:Acme]] next', + ), + ).toEqual([ + { + kind: 'record', + fullMatch: + '[[record:company:a1b2c3d4-e5f6-7890-abcd-ef1234567890:Acme]]', + index: 8, + objectNameSingular: 'company', + recordId: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890', + displayName: 'Acme', + }, + ]); + }); + + it('should still support legacy ]] inside display names', () => { + expect( + findChatReferences( + 'The company is [[record:company:a1b2c3d4-e5f6-7890-abcd-ef1234567890:[test] ]] [test] [test] ###]], created on July 21', + ), + ).toEqual([ + { + kind: 'record', + fullMatch: + '[[record:company:a1b2c3d4-e5f6-7890-abcd-ef1234567890:[test] ]] [test] [test] ###]]', + index: 15, + objectNameSingular: 'company', + recordId: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890', + displayName: '[test] ]] [test] [test] ###', + }, + ]); + }); + + it('should find multiple tagged references without consuming into the next one', () => { + expect( + findChatReferences( + 'Merge [[person:11111111-1111-1111-1111-111111111111:Alice[[/record]] into [[record:person:22222222-2222-2222-2222-222222222222:Bob[[/record]]', + ), + ).toEqual([ + { + kind: 'record', + fullMatch: + '[[person:11111111-1111-1111-1111-111111111111:Alice[[/record]]', + index: 6, + objectNameSingular: 'person', + recordId: '11111111-1111-1111-1111-111111111111', + displayName: 'Alice', + }, + { + kind: 'record', + fullMatch: + '[[record:person:22222222-2222-2222-2222-222222222222:Bob[[/record]]', + index: 74, + objectNameSingular: 'person', + recordId: '22222222-2222-2222-2222-222222222222', + displayName: 'Bob', + }, + ]); + }); + + it('should find an object reference', () => { + expect( + findChatReferences('Open [[object:partner:Partners[[/object]] to start'), + ).toEqual([ + { + kind: 'object', + fullMatch: '[[object:partner:Partners[[/object]]', + index: 5, + objectNameSingular: 'partner', + displayName: 'Partners', + }, + ]); + }); + + it('should find a field reference instead of reading it as a record', () => { + expect( + findChatReferences( + 'The [[field:33333333-3333-3333-3333-333333333333:Stage[[/field]] field', + ), + ).toEqual([ + { + kind: 'field', + fullMatch: + '[[field:33333333-3333-3333-3333-333333333333:Stage[[/field]]', + index: 4, + fieldMetadataItemId: '33333333-3333-3333-3333-333333333333', + displayName: 'Stage', + }, + ]); + }); + + it('should find a view reference instead of reading it as a record', () => { + expect( + findChatReferences( + 'See [[view:44444444-4444-4444-4444-444444444444:All Companies[[/view]]', + ), + ).toEqual([ + { + kind: 'view', + fullMatch: + '[[view:44444444-4444-4444-4444-444444444444:All Companies[[/view]]', + index: 4, + viewId: '44444444-4444-4444-4444-444444444444', + displayName: 'All Companies', + }, + ]); + }); + + it('should read an explicit record prefix as a record even when the object is named view', () => { + expect( + findChatReferences( + 'Open [[record:view:44444444-4444-4444-4444-444444444444:Quarterly[[/record]]', + ), + ).toEqual([ + { + kind: 'record', + fullMatch: + '[[record:view:44444444-4444-4444-4444-444444444444:Quarterly[[/record]]', + index: 5, + objectNameSingular: 'view', + recordId: '44444444-4444-4444-4444-444444444444', + displayName: 'Quarterly', + }, + ]); + }); + + it('should drop a metadata reference closed by a foreign tag', () => { + expect( + findChatReferences( + 'See [[view:44444444-4444-4444-4444-444444444444:All Companies[[/record]]', + ), + ).toEqual([]); + }); + + it('should drop a metadata reference closed by a bare legacy terminator', () => { + expect( + findChatReferences('Open [[object:partner:Partners]] to start'), + ).toEqual([]); + }); + + it('should not let a legacy record swallow a foreign close tag', () => { + expect( + findChatReferences( + '[[record:company:a1b2c3d4-e5f6-7890-abcd-ef1234567890:Acme]] blah [[/object]]', + ), + ).toEqual([ + { + kind: 'record', + fullMatch: + '[[record:company:a1b2c3d4-e5f6-7890-abcd-ef1234567890:Acme]]', + index: 0, + objectNameSingular: 'company', + recordId: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890', + displayName: 'Acme', + }, + ]); + }); + + it('should match object names containing digits', () => { + expect( + findChatReferences( + '[[record:company2:a1b2c3d4-e5f6-7890-abcd-ef1234567890:Acme[[/record]] and [[object:company2:Companies 2[[/object]]', + ), + ).toEqual([ + { + kind: 'record', + fullMatch: + '[[record:company2:a1b2c3d4-e5f6-7890-abcd-ef1234567890:Acme[[/record]]', + index: 0, + objectNameSingular: 'company2', + recordId: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890', + displayName: 'Acme', + }, + { + kind: 'object', + fullMatch: '[[object:company2:Companies 2[[/object]]', + index: 75, + objectNameSingular: 'company2', + displayName: 'Companies 2', + }, + ]); + }); + + it('should find every kind in a single string', () => { + const references = findChatReferences( + 'The [[view:44444444-4444-4444-4444-444444444444:Pipeline[[/view]] view of [[object:partner:Partners[[/object]] groups [[record:person:11111111-1111-1111-1111-111111111111:Alice[[/record]] by [[field:33333333-3333-3333-3333-333333333333:Stage[[/field]]', + ); + + expect(references.map((reference) => reference.kind)).toEqual([ + 'view', + 'object', + 'record', + 'field', + ]); + expect(references.map((reference) => reference.displayName)).toEqual([ + 'Pipeline', + 'Partners', + 'Alice', + 'Stage', + ]); + }); + + it('should find adjacent references without a separator', () => { + const references = findChatReferences( + '[[object:partner:Partners[[/object]][[object:company:Companies[[/object]]', + ); + + expect(references).toHaveLength(2); + expect(references[0].displayName).toBe('Partners'); + expect(references[1].displayName).toBe('Companies'); + expect(references[1].index).toBe(36); + }); + + it('should drop an unclosed reference', () => { + expect(findChatReferences('Open [[object:partner:Partners')).toEqual([]); + }); +}); diff --git a/packages/twenty-front/src/modules/ai/utils/__tests__/findRecordReferences.test.ts b/packages/twenty-front/src/modules/ai/utils/__tests__/findRecordReferences.test.ts deleted file mode 100644 index f524aa9c2a..0000000000 --- a/packages/twenty-front/src/modules/ai/utils/__tests__/findRecordReferences.test.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { findRecordReferences } from '@/ai/utils/findRecordReferences'; - -describe('findRecordReferences', () => { - it('should leave plain text without matches', () => { - expect(findRecordReferences('Which company should we contact?')).toEqual( - [], - ); - }); - - it('should find a tagged record reference', () => { - expect( - findRecordReferences( - 'Contact [[record:company:a1b2c3d4-e5f6-7890-abcd-ef1234567890:Acme[[/record]] next', - ), - ).toEqual([ - { - fullMatch: - '[[record:company:a1b2c3d4-e5f6-7890-abcd-ef1234567890:Acme[[/record]]', - index: 8, - objectNameSingular: 'company', - recordId: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890', - displayName: 'Acme', - }, - ]); - }); - - it('should include ]] inside a tagged display name', () => { - expect( - findRecordReferences( - 'The company is [[record:company:a1b2c3d4-e5f6-7890-abcd-ef1234567890:[test] ]] [test] [test] ###[[/record]], created on July 21', - ), - ).toEqual([ - { - fullMatch: - '[[record:company:a1b2c3d4-e5f6-7890-abcd-ef1234567890:[test] ]] [test] [test] ###[[/record]]', - index: 15, - objectNameSingular: 'company', - recordId: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890', - displayName: '[test] ]] [test] [test] ###', - }, - ]); - }); - - it('should still support legacy ]] terminators', () => { - expect( - findRecordReferences( - 'Contact [[record:company:a1b2c3d4-e5f6-7890-abcd-ef1234567890:Acme]] next', - ), - ).toEqual([ - { - fullMatch: - '[[record:company:a1b2c3d4-e5f6-7890-abcd-ef1234567890:Acme]]', - index: 8, - objectNameSingular: 'company', - recordId: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890', - displayName: 'Acme', - }, - ]); - }); - - it('should still support legacy ]] inside display names', () => { - expect( - findRecordReferences( - 'The company is [[record:company:a1b2c3d4-e5f6-7890-abcd-ef1234567890:[test] ]] [test] [test] ###]], created on July 21', - ), - ).toEqual([ - { - fullMatch: - '[[record:company:a1b2c3d4-e5f6-7890-abcd-ef1234567890:[test] ]] [test] [test] ###]]', - index: 15, - objectNameSingular: 'company', - recordId: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890', - displayName: '[test] ]] [test] [test] ###', - }, - ]); - }); - - it('should find multiple tagged references without consuming into the next one', () => { - expect( - findRecordReferences( - 'Merge [[person:11111111-1111-1111-1111-111111111111:Alice[[/record]] into [[record:person:22222222-2222-2222-2222-222222222222:Bob[[/record]]', - ), - ).toEqual([ - { - fullMatch: - '[[person:11111111-1111-1111-1111-111111111111:Alice[[/record]]', - index: 6, - objectNameSingular: 'person', - recordId: '11111111-1111-1111-1111-111111111111', - displayName: 'Alice', - }, - { - fullMatch: - '[[record:person:22222222-2222-2222-2222-222222222222:Bob[[/record]]', - index: 74, - objectNameSingular: 'person', - recordId: '22222222-2222-2222-2222-222222222222', - displayName: 'Bob', - }, - ]); - }); -}); diff --git a/packages/twenty-front/src/modules/ai/utils/__tests__/protectChatReferencesForMarkdown.test.ts b/packages/twenty-front/src/modules/ai/utils/__tests__/protectChatReferencesForMarkdown.test.ts new file mode 100644 index 0000000000..c0de47f92a --- /dev/null +++ b/packages/twenty-front/src/modules/ai/utils/__tests__/protectChatReferencesForMarkdown.test.ts @@ -0,0 +1,87 @@ +import { protectChatReferencesForMarkdown } from '@/ai/utils/protectChatReferencesForMarkdown'; + +describe('protectChatReferencesForMarkdown', () => { + it('should leave plain text unchanged', () => { + expect( + protectChatReferencesForMarkdown('Which company should we contact?'), + ).toBe('Which company should we contact?'); + }); + + it('should rewrite legacy refs to the tagged format', () => { + expect( + protectChatReferencesForMarkdown( + 'Contact [[record:company:a1b2c3d4-e5f6-7890-abcd-ef1234567890:Acme]] next', + ), + ).toBe( + 'Contact [[record:company:a1b2c3d4-e5f6-7890-abcd-ef1234567890:Acme[[/record]] next', + ); + }); + + it('should escape backticks in labels and close with the tag', () => { + expect( + protectChatReferencesForMarkdown( + 'See [[record:workflow:a1b2c3d4-e5f6-7890-abcd-ef1234567890:Workflow `UPDATE_RECORD` step[[/record]]', + ), + ).toBe( + 'See [[record:workflow:a1b2c3d4-e5f6-7890-abcd-ef1234567890:Workflow \\`UPDATE\\_RECORD\\` step[[/record]]', + ); + }); + + it('should escape square brackets and ]] inside labels', () => { + expect( + protectChatReferencesForMarkdown( + 'Open [[record:company:a1b2c3d4-e5f6-7890-abcd-ef1234567890:[test] ]] [test] [test] ###[[/record]]', + ), + ).toBe( + 'Open [[record:company:a1b2c3d4-e5f6-7890-abcd-ef1234567890:\\[test\\] \\]\\] \\[test\\] \\[test\\] \\#\\#\\#[[/record]]', + ); + }); + + it('should leave colons in labels unchanged', () => { + expect( + protectChatReferencesForMarkdown( + 'Ping [[record:person:a1b2c3d4-e5f6-7890-abcd-ef1234567890:Doe: Jane[[/record]]', + ), + ).toBe( + 'Ping [[record:person:a1b2c3d4-e5f6-7890-abcd-ef1234567890:Doe: Jane[[/record]]', + ); + }); + + it('should escape an object label without touching its name', () => { + expect( + protectChatReferencesForMarkdown( + 'Open [[object:partner:Partners (EMEA)[[/object]]', + ), + ).toBe('Open [[object:partner:Partners \\(EMEA\\)[[/object]]'); + }); + + it('should escape a field label without touching its id', () => { + expect( + protectChatReferencesForMarkdown( + 'The [[field:33333333-3333-3333-3333-333333333333:Next step[[/field]] field', + ), + ).toBe( + 'The [[field:33333333-3333-3333-3333-333333333333:Next step[[/field]] field', + ); + }); + + it('should escape a view label without touching its id', () => { + expect( + protectChatReferencesForMarkdown( + 'See [[view:44444444-4444-4444-4444-444444444444:Q1 - pipeline[[/view]]', + ), + ).toBe( + 'See [[view:44444444-4444-4444-4444-444444444444:Q1 \\- pipeline[[/view]]', + ); + }); + + it('should rewrite every kind in a mixed string', () => { + expect( + protectChatReferencesForMarkdown( + 'The [[view:44444444-4444-4444-4444-444444444444:Pipeline[[/view]] view of [[object:partner:Partners[[/object]] groups [[record:person:11111111-1111-1111-1111-111111111111:Alice]] by [[field:33333333-3333-3333-3333-333333333333:Stage[[/field]]', + ), + ).toBe( + 'The [[view:44444444-4444-4444-4444-444444444444:Pipeline[[/view]] view of [[object:partner:Partners[[/object]] groups [[record:person:11111111-1111-1111-1111-111111111111:Alice[[/record]] by [[field:33333333-3333-3333-3333-333333333333:Stage[[/field]]', + ); + }); +}); diff --git a/packages/twenty-front/src/modules/ai/utils/__tests__/protectRecordReferencesForMarkdown.test.ts b/packages/twenty-front/src/modules/ai/utils/__tests__/protectRecordReferencesForMarkdown.test.ts deleted file mode 100644 index f08435f3d1..0000000000 --- a/packages/twenty-front/src/modules/ai/utils/__tests__/protectRecordReferencesForMarkdown.test.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { protectRecordReferencesForMarkdown } from '@/ai/utils/protectRecordReferencesForMarkdown'; - -describe('protectRecordReferencesForMarkdown', () => { - it('should leave plain text unchanged', () => { - expect( - protectRecordReferencesForMarkdown('Which company should we contact?'), - ).toBe('Which company should we contact?'); - }); - - it('should rewrite legacy refs to the tagged format', () => { - expect( - protectRecordReferencesForMarkdown( - 'Contact [[record:company:a1b2c3d4-e5f6-7890-abcd-ef1234567890:Acme]] next', - ), - ).toBe( - 'Contact [[record:company:a1b2c3d4-e5f6-7890-abcd-ef1234567890:Acme[[/record]] next', - ); - }); - - it('should escape backticks in labels and close with the tag', () => { - expect( - protectRecordReferencesForMarkdown( - 'See [[record:workflow:a1b2c3d4-e5f6-7890-abcd-ef1234567890:Workflow `UPDATE_RECORD` step[[/record]]', - ), - ).toBe( - 'See [[record:workflow:a1b2c3d4-e5f6-7890-abcd-ef1234567890:Workflow \\`UPDATE\\_RECORD\\` step[[/record]]', - ); - }); - - it('should escape square brackets and ]] inside labels', () => { - expect( - protectRecordReferencesForMarkdown( - 'Open [[record:company:a1b2c3d4-e5f6-7890-abcd-ef1234567890:[test] ]] [test] [test] ###[[/record]]', - ), - ).toBe( - 'Open [[record:company:a1b2c3d4-e5f6-7890-abcd-ef1234567890:\\[test\\] \\]\\] \\[test\\] \\[test\\] \\#\\#\\#[[/record]]', - ); - }); - - it('should leave colons in labels unchanged', () => { - expect( - protectRecordReferencesForMarkdown( - 'Ping [[record:person:a1b2c3d4-e5f6-7890-abcd-ef1234567890:Doe: Jane[[/record]]', - ), - ).toBe( - 'Ping [[record:person:a1b2c3d4-e5f6-7890-abcd-ef1234567890:Doe: Jane[[/record]]', - ); - }); -}); diff --git a/packages/twenty-front/src/modules/ai/utils/escapeMarkdownForChatReference.ts b/packages/twenty-front/src/modules/ai/utils/escapeMarkdownForChatReference.ts new file mode 100644 index 0000000000..104bbe3659 --- /dev/null +++ b/packages/twenty-front/src/modules/ai/utils/escapeMarkdownForChatReference.ts @@ -0,0 +1,2 @@ +export const escapeMarkdownForChatReference = (displayName: string): string => + displayName.replace(/([\\`*_{}[\]()#+\-.!|~>])/g, '\\$1'); diff --git a/packages/twenty-front/src/modules/ai/utils/findChatReferenceClosing.ts b/packages/twenty-front/src/modules/ai/utils/findChatReferenceClosing.ts new file mode 100644 index 0000000000..25e7ee4650 --- /dev/null +++ b/packages/twenty-front/src/modules/ai/utils/findChatReferenceClosing.ts @@ -0,0 +1,44 @@ +import { ANY_CHAT_REFERENCE_CLOSE_TAG_REGEX } from '@/ai/constants/AnyChatReferenceCloseTagRegex'; +import { type ChatReferenceClosing } from '@/ai/types/ChatReferenceClosing'; +import { type ChatReferenceKind } from '@/ai/types/ChatReferenceKind'; +import { getChatReferenceCloseTag } from '@/ai/utils/getChatReferenceCloseTag'; +import { isDefined } from 'twenty-shared/utils'; + +const LEGACY_RECORD_REFERENCE_CLOSE_TAG = ']]'; + +export const findChatReferenceClosing = ({ + displayNameWindow, + kind, +}: { + displayNameWindow: string; + kind: ChatReferenceKind; +}): ChatReferenceClosing | undefined => { + const closeTag = getChatReferenceCloseTag(kind); + const closeTagIndex = displayNameWindow.indexOf(closeTag); + + if (closeTagIndex !== -1) { + return { index: closeTagIndex, length: closeTag.length }; + } + + if (kind !== 'record') { + return undefined; + } + + const foreignCloseTagMatch = + ANY_CHAT_REFERENCE_CLOSE_TAG_REGEX.exec(displayNameWindow); + const legacySearchSpace = isDefined(foreignCloseTagMatch) + ? displayNameWindow.slice(0, foreignCloseTagMatch.index) + : displayNameWindow; + const legacyCloseIndex = legacySearchSpace.lastIndexOf( + LEGACY_RECORD_REFERENCE_CLOSE_TAG, + ); + + if (legacyCloseIndex === -1) { + return undefined; + } + + return { + index: legacyCloseIndex, + length: LEGACY_RECORD_REFERENCE_CLOSE_TAG.length, + }; +}; diff --git a/packages/twenty-front/src/modules/ai/utils/findChatReferences.ts b/packages/twenty-front/src/modules/ai/utils/findChatReferences.ts new file mode 100644 index 0000000000..7df580ce4c --- /dev/null +++ b/packages/twenty-front/src/modules/ai/utils/findChatReferences.ts @@ -0,0 +1,48 @@ +import { CHAT_REFERENCE_START_REGEX } from '@/ai/constants/ChatReferenceStartRegex'; +import { type ChatReferenceMatch } from '@/ai/types/ChatReferenceMatch'; +import { type ChatReferenceStart } from '@/ai/types/ChatReferenceStart'; +import { findChatReferenceClosing } from '@/ai/utils/findChatReferenceClosing'; +import { getChatReferenceStartFromMatch } from '@/ai/utils/getChatReferenceStartFromMatch'; +import { isDefined } from 'twenty-shared/utils'; + +export const findChatReferences = (text: string): ChatReferenceMatch[] => { + const starts: ChatReferenceStart[] = []; + + CHAT_REFERENCE_START_REGEX.lastIndex = 0; + + let startMatch; + + while ((startMatch = CHAT_REFERENCE_START_REGEX.exec(text)) !== null) { + starts.push(getChatReferenceStartFromMatch(startMatch)); + } + + return starts.flatMap((start, startIndex) => { + const displayNameStart = start.index + start.prefixLength; + const windowEnd = + startIndex + 1 < starts.length + ? starts[startIndex + 1].index + : text.length; + const displayNameWindow = text.slice(displayNameStart, windowEnd); + + const closing = findChatReferenceClosing({ + displayNameWindow, + kind: start.identity.kind, + }); + + if (!isDefined(closing)) { + return []; + } + + return [ + { + ...start.identity, + fullMatch: text.slice( + start.index, + displayNameStart + closing.index + closing.length, + ), + index: start.index, + displayName: displayNameWindow.slice(0, closing.index), + }, + ]; + }); +}; diff --git a/packages/twenty-front/src/modules/ai/utils/findRecordReferences.ts b/packages/twenty-front/src/modules/ai/utils/findRecordReferences.ts deleted file mode 100644 index 7465938e38..0000000000 --- a/packages/twenty-front/src/modules/ai/utils/findRecordReferences.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { RECORD_REFERENCE_CLOSE_TAG } from '@/ai/constants/RecordReferenceCloseTag'; -import { type RecordReferenceMatch } from '@/ai/types/RecordReferenceMatch'; - -const RECORD_REFERENCE_START_REGEX = - /\[\[(?:record:)?([a-zA-Z]+):([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}):/g; - -export const findRecordReferences = (text: string): RecordReferenceMatch[] => { - const starts: Array<{ - index: number; - prefixLength: number; - objectNameSingular: string; - recordId: string; - }> = []; - - RECORD_REFERENCE_START_REGEX.lastIndex = 0; - - let startMatch; - - while ((startMatch = RECORD_REFERENCE_START_REGEX.exec(text)) !== null) { - starts.push({ - index: startMatch.index, - prefixLength: startMatch[0].length, - objectNameSingular: startMatch[1], - recordId: startMatch[2], - }); - } - - return starts.flatMap((start, startIndex) => { - const displayNameStart = start.index + start.prefixLength; - const windowEnd = - startIndex + 1 < starts.length - ? starts[startIndex + 1].index - : text.length; - const window = text.slice(displayNameStart, windowEnd); - - const closeTagIndexInWindow = window.indexOf(RECORD_REFERENCE_CLOSE_TAG); - - const closingIndexInWindow = - closeTagIndexInWindow !== -1 - ? closeTagIndexInWindow - : window.lastIndexOf(']]'); - - if (closingIndexInWindow === -1) { - return []; - } - - const closeLength = - closeTagIndexInWindow !== -1 ? RECORD_REFERENCE_CLOSE_TAG.length : 2; - const displayName = window.slice(0, closingIndexInWindow); - const fullMatchEnd = displayNameStart + closingIndexInWindow + closeLength; - - return [ - { - fullMatch: text.slice(start.index, fullMatchEnd), - index: start.index, - objectNameSingular: start.objectNameSingular, - recordId: start.recordId, - displayName, - }, - ]; - }); -}; diff --git a/packages/twenty-front/src/modules/ai/utils/formatChatReference.ts b/packages/twenty-front/src/modules/ai/utils/formatChatReference.ts new file mode 100644 index 0000000000..272f69d813 --- /dev/null +++ b/packages/twenty-front/src/modules/ai/utils/formatChatReference.ts @@ -0,0 +1,8 @@ +import { type ChatReferenceIdentity } from '@/ai/types/ChatReferenceIdentity'; +import { getChatReferenceCloseTag } from '@/ai/utils/getChatReferenceCloseTag'; +import { getChatReferenceIdentitySegment } from '@/ai/utils/getChatReferenceIdentitySegment'; + +export const formatChatReference = ( + reference: ChatReferenceIdentity & { displayName: string }, +): string => + `[[${reference.kind}:${getChatReferenceIdentitySegment(reference)}:${reference.displayName}${getChatReferenceCloseTag(reference.kind)}`; diff --git a/packages/twenty-front/src/modules/ai/utils/formatRecordReference.ts b/packages/twenty-front/src/modules/ai/utils/formatRecordReference.ts index 8ddcb49850..457f37e00f 100644 --- a/packages/twenty-front/src/modules/ai/utils/formatRecordReference.ts +++ b/packages/twenty-front/src/modules/ai/utils/formatRecordReference.ts @@ -1,4 +1,4 @@ -import { RECORD_REFERENCE_CLOSE_TAG } from '@/ai/constants/RecordReferenceCloseTag'; +import { formatChatReference } from '@/ai/utils/formatChatReference'; export const formatRecordReference = ({ objectNameSingular, @@ -9,4 +9,9 @@ export const formatRecordReference = ({ recordId: string; displayName: string; }): string => - `[[record:${objectNameSingular}:${recordId}:${displayName}${RECORD_REFERENCE_CLOSE_TAG}`; + formatChatReference({ + kind: 'record', + objectNameSingular, + recordId, + displayName, + }); diff --git a/packages/twenty-front/src/modules/ai/utils/getChatReferenceCloseTag.ts b/packages/twenty-front/src/modules/ai/utils/getChatReferenceCloseTag.ts new file mode 100644 index 0000000000..3eea347fac --- /dev/null +++ b/packages/twenty-front/src/modules/ai/utils/getChatReferenceCloseTag.ts @@ -0,0 +1,4 @@ +import { type ChatReferenceKind } from '@/ai/types/ChatReferenceKind'; + +export const getChatReferenceCloseTag = (kind: ChatReferenceKind): string => + `[[/${kind}]]`; diff --git a/packages/twenty-front/src/modules/ai/utils/getChatReferenceIdentitySegment.ts b/packages/twenty-front/src/modules/ai/utils/getChatReferenceIdentitySegment.ts new file mode 100644 index 0000000000..f36c18eee5 --- /dev/null +++ b/packages/twenty-front/src/modules/ai/utils/getChatReferenceIdentitySegment.ts @@ -0,0 +1,19 @@ +import { type ChatReferenceIdentity } from '@/ai/types/ChatReferenceIdentity'; +import { assertUnreachable } from 'twenty-shared/utils'; + +export const getChatReferenceIdentitySegment = ( + identity: ChatReferenceIdentity, +): string => { + switch (identity.kind) { + case 'record': + return `${identity.objectNameSingular}:${identity.recordId}`; + case 'object': + return identity.objectNameSingular; + case 'field': + return identity.fieldMetadataItemId; + case 'view': + return identity.viewId; + default: + return assertUnreachable(identity); + } +}; diff --git a/packages/twenty-front/src/modules/ai/utils/getChatReferenceStartFromMatch.ts b/packages/twenty-front/src/modules/ai/utils/getChatReferenceStartFromMatch.ts new file mode 100644 index 0000000000..a9266827aa --- /dev/null +++ b/packages/twenty-front/src/modules/ai/utils/getChatReferenceStartFromMatch.ts @@ -0,0 +1,37 @@ +import { type ChatReferenceStart } from '@/ai/types/ChatReferenceStart'; +import { isDefined } from 'twenty-shared/utils'; + +export const getChatReferenceStartFromMatch = ( + match: RegExpExecArray, +): ChatReferenceStart => { + const { + objectNameSingular, + fieldMetadataItemId, + viewId, + recordObjectNameSingular, + recordId, + } = match.groups ?? {}; + + const position = { index: match.index, prefixLength: match[0].length }; + + if (isDefined(objectNameSingular)) { + return { ...position, identity: { kind: 'object', objectNameSingular } }; + } + + if (isDefined(fieldMetadataItemId)) { + return { ...position, identity: { kind: 'field', fieldMetadataItemId } }; + } + + if (isDefined(viewId)) { + return { ...position, identity: { kind: 'view', viewId } }; + } + + return { + ...position, + identity: { + kind: 'record', + objectNameSingular: recordObjectNameSingular, + recordId, + }, + }; +}; diff --git a/packages/twenty-front/src/modules/ai/utils/protectChatReferencesForMarkdown.ts b/packages/twenty-front/src/modules/ai/utils/protectChatReferencesForMarkdown.ts new file mode 100644 index 0000000000..b6cced8e3b --- /dev/null +++ b/packages/twenty-front/src/modules/ai/utils/protectChatReferencesForMarkdown.ts @@ -0,0 +1,30 @@ +import { escapeMarkdownForChatReference } from '@/ai/utils/escapeMarkdownForChatReference'; +import { findChatReferences } from '@/ai/utils/findChatReferences'; +import { formatChatReference } from '@/ai/utils/formatChatReference'; + +export const protectChatReferencesForMarkdown = (text: string): string => { + const references = findChatReferences(text); + + if (references.length === 0) { + return text; + } + + const parts: string[] = []; + let lastIndex = 0; + + for (const reference of references) { + parts.push(text.slice(lastIndex, reference.index)); + parts.push( + formatChatReference({ + ...reference, + displayName: escapeMarkdownForChatReference(reference.displayName), + }), + ); + + lastIndex = reference.index + reference.fullMatch.length; + } + + parts.push(text.slice(lastIndex)); + + return parts.join(''); +}; diff --git a/packages/twenty-front/src/modules/ai/utils/protectRecordReferencesForMarkdown.ts b/packages/twenty-front/src/modules/ai/utils/protectRecordReferencesForMarkdown.ts deleted file mode 100644 index d9cbcabfca..0000000000 --- a/packages/twenty-front/src/modules/ai/utils/protectRecordReferencesForMarkdown.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { findRecordReferences } from '@/ai/utils/findRecordReferences'; -import { formatRecordReference } from '@/ai/utils/formatRecordReference'; - -const escapeMarkdown = (text: string): string => - text.replace(/([\\`*_{}[\]()#+\-.!|~>])/g, '\\$1'); - -export const protectRecordReferencesForMarkdown = (text: string): string => { - const references = findRecordReferences(text); - let result = text; - - for (let index = references.length - 1; index >= 0; index--) { - const reference = references[index]; - const replacement = formatRecordReference({ - objectNameSingular: reference.objectNameSingular, - recordId: reference.recordId, - displayName: escapeMarkdown(reference.displayName), - }); - - result = - result.slice(0, reference.index) + - replacement + - result.slice(reference.index + reference.fullMatch.length); - } - - return result; -}; diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/constants/chat-system-prompts.const.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/constants/chat-system-prompts.const.ts index 7e3d37994c..09f7145609 100644 --- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/constants/chat-system-prompts.const.ts +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/constants/chat-system-prompts.const.ts @@ -85,5 +85,24 @@ Record References - IMPORTANT: - The recordId MUST be a real UUID (like "abc12345-1234-5678-abcd-123456789012") - DO NOT create record references before calling the tool - DO NOT use placeholder IDs like "rec-snowflake" or "rec-person-1" -- If a tool hasn't been called yet, don't reference records that don't exist`, +- If a tool hasn't been called yet, don't reference records that don't exist + +Metadata References: +Whenever you name an object, a field, or a view in your prose, write it as a metadata reference instead of plain text. Each one becomes a chip the user can click. + +- Object: [[object:objectNameSingular:displayName[[/object]] + - Example: [[object:company:Companies[[/object]] + - Use the \`nameSingular\` from \`get_object_metadata\` or \`create_object_metadata\` (NOT the label, NOT the plural, NOT the id) + - This is the only reference you may write for something that does not exist yet: when you propose creating an object, reference it with the \`nameSingular\` you intend to use and it renders as a chip without a link +- Field: [[field:fieldMetadataId:displayName[[/field]] + - Example: [[field:abc12345-1234-5678-abcd-123456789012:Annual Recurring Revenue[[/field]] + - Use the \`id\` returned by \`get_field_metadata\`, \`create_field_metadata\`, or the \`fields\` array of \`get_object_metadata\` +- View: [[view:viewId:displayName[[/view]] + - Example: [[view:abc12345-1234-5678-abcd-123456789012:All Companies[[/view]] + - Use the \`id\` returned by \`get_views\`, \`create_view\`, or \`upsert_complete_view\` + +- The displayName is what the user reads, so use the human-readable label ("Annual Recurring Revenue"), not the technical name +- Field and view ids MUST be real UUIDs copied from a tool response - never invent one, and never reference a field or view before the tool that returns it has run +- Always close a reference with its own tag: \`[[/object]]\`, \`[[/field]]\`, \`[[/view]]\`. A mismatched closing tag drops the chip +- Use metadata references only in paragraphs, lists, or markdown tables (\`| ... |\`); never in headings, code, links, or raw HTML`, };