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 index d8541b169a..63d878b4a6 100644 --- a/packages/twenty-front/src/modules/ai/components/__stories__/ChatReferenceChip.stories.tsx +++ b/packages/twenty-front/src/modules/ai/components/__stories__/ChatReferenceChip.stories.tsx @@ -113,22 +113,6 @@ export const ExistingMetadata: Story = { }, }; -export const SurplusClosingBrackets: Story = { - args: { - text: `I created the ${formatChatReference({ - kind: 'object', - objectNameSingular: 'company', - displayName: 'Companies', - })}] object.`, - }, - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - - expect(await canvas.findByText('Companies')).toBeVisible(); - expect(canvasElement).toHaveTextContent('I created the Companies object.'); - }, -}; - 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( 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 index 5eed72153f..96fb0be745 100644 --- a/packages/twenty-front/src/modules/ai/components/__tests__/TextWithChatReferences.test.tsx +++ b/packages/twenty-front/src/modules/ai/components/__tests__/TextWithChatReferences.test.tsx @@ -70,9 +70,9 @@ describe('TextWithChatReferences', () => { expect(screen.queryByTestId('record-link')).not.toBeInTheDocument(); }); - it('should replace tagged record references with RecordLink chips', () => { + it('should replace record references with RecordLink chips', () => { render( - , + , ); expect(screen.getByTestId('record-link')).toHaveTextContent('Acme'); @@ -80,18 +80,9 @@ describe('TextWithChatReferences', () => { 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'); @@ -102,49 +93,33 @@ describe('TextWithChatReferences', () => { expect(screen.queryByText(/\[\[/)).not.toBeInTheDocument(); }); - it('should chip tagged labels that contain backticks, brackets, colons, and ]]', () => { + it('should chip labels that contain backticks and colons', () => { render( - , + , ); const recordLinks = screen.getAllByTestId('record-link'); - expect(recordLinks).toHaveLength(3); + expect(recordLinks).toHaveLength(2); expect(recordLinks[0]).toHaveTextContent('Workflow `UPDATE_RECORD` step'); - expect(recordLinks[1]).toHaveTextContent('[test] ]] [test] [test] ###'); - expect(recordLinks[2]).toHaveTextContent('Doe: Jane'); + expect(recordLinks[1]).toHaveTextContent('Doe: Jane'); expect(screen.queryByText(/\[\[record:/)).not.toBeInTheDocument(); }); - it('should not leave a surplus bracket after a chip', () => { + it('should render a reference using a retired closing tag as plain text', () => { render( - , + , ); - expect(screen.getByTestId('object-link')).toHaveTextContent( - 'Opportunities', - ); - expect(screen.getByText(/Created/)).toHaveTextContent( - 'Created Opportunities.', - ); - }); - - it('should not leave the extra brackets of an over-wrapped reference', () => { - render( - , - ); - - expect(screen.getByTestId('object-link')).toHaveTextContent( - 'Opportunities', - ); - expect(screen.getByText(/Created/)).toHaveTextContent( - 'Created Opportunities now', + expect(screen.queryByTestId('record-link')).not.toBeInTheDocument(); + expect(screen.getByText(/Contact/)).toHaveTextContent( + 'Contact [[record:company:a1b2c3d4-e5f6-7890-abcd-ef1234567890:Acme[[/record]] next', ); }); it('should route each reference kind to its own chip', () => { render( - , + , ); expect(screen.getByTestId('view-link')).toHaveAttribute( diff --git a/packages/twenty-front/src/modules/ai/constants/AnyChatReferenceCloseTagRegex.ts b/packages/twenty-front/src/modules/ai/constants/AnyChatReferenceCloseTagRegex.ts deleted file mode 100644 index a3dbaba49f..0000000000 --- a/packages/twenty-front/src/modules/ai/constants/AnyChatReferenceCloseTagRegex.ts +++ /dev/null @@ -1,5 +0,0 @@ -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/ChatReferenceFieldPattern.ts b/packages/twenty-front/src/modules/ai/constants/ChatReferenceFieldPattern.ts new file mode 100644 index 0000000000..7040fbe2d4 --- /dev/null +++ b/packages/twenty-front/src/modules/ai/constants/ChatReferenceFieldPattern.ts @@ -0,0 +1,4 @@ +import { CHAT_REFERENCE_LABEL_PATTERN } from '@/ai/constants/ChatReferenceLabelPattern'; +import { CHAT_REFERENCE_UUID_PATTERN } from '@/ai/constants/ChatReferenceUuidPattern'; + +export const CHAT_REFERENCE_FIELD_PATTERN = `\\[\\[field:(?${CHAT_REFERENCE_UUID_PATTERN}):(?${CHAT_REFERENCE_LABEL_PATTERN})\\]\\]`; diff --git a/packages/twenty-front/src/modules/ai/constants/ChatReferenceKinds.ts b/packages/twenty-front/src/modules/ai/constants/ChatReferenceKinds.ts deleted file mode 100644 index 82334264ec..0000000000 --- a/packages/twenty-front/src/modules/ai/constants/ChatReferenceKinds.ts +++ /dev/null @@ -1,6 +0,0 @@ -export const CHAT_REFERENCE_KINDS = [ - 'record', - 'object', - 'field', - 'view', -] as const; diff --git a/packages/twenty-front/src/modules/ai/constants/ChatReferenceLabelPattern.ts b/packages/twenty-front/src/modules/ai/constants/ChatReferenceLabelPattern.ts new file mode 100644 index 0000000000..fb08cedfb4 --- /dev/null +++ b/packages/twenty-front/src/modules/ai/constants/ChatReferenceLabelPattern.ts @@ -0,0 +1,2 @@ +// Excluding brackets and line breaks stops an unclosed reference from swallowing the text and markers after it. +export const CHAT_REFERENCE_LABEL_PATTERN = '[^\\[\\]\\n]*'; diff --git a/packages/twenty-front/src/modules/ai/constants/ChatReferenceObjectPattern.ts b/packages/twenty-front/src/modules/ai/constants/ChatReferenceObjectPattern.ts new file mode 100644 index 0000000000..6e2b6a62a9 --- /dev/null +++ b/packages/twenty-front/src/modules/ai/constants/ChatReferenceObjectPattern.ts @@ -0,0 +1,4 @@ +import { CHAT_REFERENCE_LABEL_PATTERN } from '@/ai/constants/ChatReferenceLabelPattern'; +import { CHAT_REFERENCE_METADATA_NAME_PATTERN } from '@/ai/constants/ChatReferenceMetadataNamePattern'; + +export const CHAT_REFERENCE_OBJECT_PATTERN = `\\[\\[object:(?${CHAT_REFERENCE_METADATA_NAME_PATTERN}):(?${CHAT_REFERENCE_LABEL_PATTERN})\\]\\]`; diff --git a/packages/twenty-front/src/modules/ai/constants/ChatReferenceOpenPattern.ts b/packages/twenty-front/src/modules/ai/constants/ChatReferenceOpenPattern.ts deleted file mode 100644 index 3edde75f9c..0000000000 --- a/packages/twenty-front/src/modules/ai/constants/ChatReferenceOpenPattern.ts +++ /dev/null @@ -1,5 +0,0 @@ -// The lookbehind anchors the match to the start of a bracket run. Without it, -// every position inside a long run is a candidate start and the greedy + makes -// the scan quadratic in the run length; a run start always yields the same -// match, so no valid marker is lost. -export const CHAT_REFERENCE_OPEN_PATTERN = '(?${CHAT_REFERENCE_METADATA_NAME_PATTERN}):(?${CHAT_REFERENCE_UUID_PATTERN}):(?${CHAT_REFERENCE_LABEL_PATTERN})\\]\\]`; diff --git a/packages/twenty-front/src/modules/ai/constants/ChatReferenceRegex.ts b/packages/twenty-front/src/modules/ai/constants/ChatReferenceRegex.ts new file mode 100644 index 0000000000..1166c13826 --- /dev/null +++ b/packages/twenty-front/src/modules/ai/constants/ChatReferenceRegex.ts @@ -0,0 +1,16 @@ +import { CHAT_REFERENCE_FIELD_PATTERN } from '@/ai/constants/ChatReferenceFieldPattern'; +import { CHAT_REFERENCE_OBJECT_PATTERN } from '@/ai/constants/ChatReferenceObjectPattern'; +import { CHAT_REFERENCE_RECORD_PATTERN } from '@/ai/constants/ChatReferenceRecordPattern'; +import { CHAT_REFERENCE_VIEW_PATTERN } from '@/ai/constants/ChatReferenceViewPattern'; + +// The record pattern 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_REGEX = new RegExp( + [ + CHAT_REFERENCE_OBJECT_PATTERN, + CHAT_REFERENCE_FIELD_PATTERN, + CHAT_REFERENCE_VIEW_PATTERN, + CHAT_REFERENCE_RECORD_PATTERN, + ].join('|'), + 'g', +); diff --git a/packages/twenty-front/src/modules/ai/constants/ChatReferenceStartRegex.ts b/packages/twenty-front/src/modules/ai/constants/ChatReferenceStartRegex.ts deleted file mode 100644 index 52a98c7187..0000000000 --- a/packages/twenty-front/src/modules/ai/constants/ChatReferenceStartRegex.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { CHAT_REFERENCE_METADATA_NAME_PATTERN } from '@/ai/constants/ChatReferenceMetadataNamePattern'; -import { CHAT_REFERENCE_OPEN_PATTERN } from '@/ai/constants/ChatReferenceOpenPattern'; -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( - [ - `${CHAT_REFERENCE_OPEN_PATTERN}object:(?${CHAT_REFERENCE_METADATA_NAME_PATTERN}):`, - `${CHAT_REFERENCE_OPEN_PATTERN}field:(?${CHAT_REFERENCE_UUID_PATTERN}):`, - `${CHAT_REFERENCE_OPEN_PATTERN}view:(?${CHAT_REFERENCE_UUID_PATTERN}):`, - `${CHAT_REFERENCE_OPEN_PATTERN}(?:record:)?(?${CHAT_REFERENCE_METADATA_NAME_PATTERN}):(?${CHAT_REFERENCE_UUID_PATTERN}):`, - ].join('|'), - 'g', -); diff --git a/packages/twenty-front/src/modules/ai/constants/ChatReferenceViewPattern.ts b/packages/twenty-front/src/modules/ai/constants/ChatReferenceViewPattern.ts new file mode 100644 index 0000000000..2087aead5b --- /dev/null +++ b/packages/twenty-front/src/modules/ai/constants/ChatReferenceViewPattern.ts @@ -0,0 +1,4 @@ +import { CHAT_REFERENCE_LABEL_PATTERN } from '@/ai/constants/ChatReferenceLabelPattern'; +import { CHAT_REFERENCE_UUID_PATTERN } from '@/ai/constants/ChatReferenceUuidPattern'; + +export const CHAT_REFERENCE_VIEW_PATTERN = `\\[\\[view:(?${CHAT_REFERENCE_UUID_PATTERN}):(?${CHAT_REFERENCE_LABEL_PATTERN})\\]\\]`; diff --git a/packages/twenty-front/src/modules/ai/types/ChatReferenceClosing.ts b/packages/twenty-front/src/modules/ai/types/ChatReferenceClosing.ts deleted file mode 100644 index a4f422612f..0000000000 --- a/packages/twenty-front/src/modules/ai/types/ChatReferenceClosing.ts +++ /dev/null @@ -1,4 +0,0 @@ -export type ChatReferenceClosing = { - index: number; - length: number; -}; diff --git a/packages/twenty-front/src/modules/ai/types/ChatReferenceKind.ts b/packages/twenty-front/src/modules/ai/types/ChatReferenceKind.ts deleted file mode 100644 index c6ddeab25c..0000000000 --- a/packages/twenty-front/src/modules/ai/types/ChatReferenceKind.ts +++ /dev/null @@ -1,3 +0,0 @@ -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/ChatReferenceStart.ts b/packages/twenty-front/src/modules/ai/types/ChatReferenceStart.ts deleted file mode 100644 index b59b2c1eaa..0000000000 --- a/packages/twenty-front/src/modules/ai/types/ChatReferenceStart.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { type ChatReferenceIdentity } from '@/ai/types/ChatReferenceIdentity'; - -export type ChatReferenceStart = { - index: number; - prefixLength: number; - openBracketLength: number; - identity: ChatReferenceIdentity; -}; 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 index 44b1894495..b163e88e14 100644 --- a/packages/twenty-front/src/modules/ai/utils/__tests__/findChatReferences.test.ts +++ b/packages/twenty-front/src/modules/ai/utils/__tests__/findChatReferences.test.ts @@ -5,43 +5,7 @@ describe('findChatReferences', () => { 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', () => { + it('should find a record reference', () => { expect( findChatReferences( 'Contact [[record:company:a1b2c3d4-e5f6-7890-abcd-ef1234567890:Acme]] next', @@ -59,34 +23,15 @@ describe('findChatReferences', () => { ]); }); - it('should still support legacy ]] inside display names', () => { + it('should find a record reference written without the record prefix', () => { expect( findChatReferences( - 'The company is [[record:company:a1b2c3d4-e5f6-7890-abcd-ef1234567890:[test] ]] [test] [test] ###]], created on July 21', + 'Merge [[person:11111111-1111-1111-1111-111111111111:Alice]] into [[record:person:22222222-2222-2222-2222-222222222222:Bob]]', ), ).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]]', + fullMatch: '[[person:11111111-1111-1111-1111-111111111111:Alice]]', index: 6, objectNameSingular: 'person', recordId: '11111111-1111-1111-1111-111111111111', @@ -94,9 +39,8 @@ describe('findChatReferences', () => { }, { kind: 'record', - fullMatch: - '[[record:person:22222222-2222-2222-2222-222222222222:Bob[[/record]]', - index: 74, + fullMatch: '[[record:person:22222222-2222-2222-2222-222222222222:Bob]]', + index: 65, objectNameSingular: 'person', recordId: '22222222-2222-2222-2222-222222222222', displayName: 'Bob', @@ -106,11 +50,11 @@ describe('findChatReferences', () => { it('should find an object reference', () => { expect( - findChatReferences('Open [[object:partner:Partners[[/object]] to start'), + findChatReferences('Open [[object:partner:Partners]] to start'), ).toEqual([ { kind: 'object', - fullMatch: '[[object:partner:Partners[[/object]]', + fullMatch: '[[object:partner:Partners]]', index: 5, objectNameSingular: 'partner', displayName: 'Partners', @@ -121,13 +65,12 @@ describe('findChatReferences', () => { 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', + 'The [[field:33333333-3333-3333-3333-333333333333:Stage]] field', ), ).toEqual([ { kind: 'field', - fullMatch: - '[[field:33333333-3333-3333-3333-333333333333:Stage[[/field]]', + fullMatch: '[[field:33333333-3333-3333-3333-333333333333:Stage]]', index: 4, fieldMetadataItemId: '33333333-3333-3333-3333-333333333333', displayName: 'Stage', @@ -138,13 +81,13 @@ describe('findChatReferences', () => { 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]]', + 'See [[view:44444444-4444-4444-4444-444444444444:All Companies]]', ), ).toEqual([ { kind: 'view', fullMatch: - '[[view:44444444-4444-4444-4444-444444444444:All Companies[[/view]]', + '[[view:44444444-4444-4444-4444-444444444444:All Companies]]', index: 4, viewId: '44444444-4444-4444-4444-444444444444', displayName: 'All Companies', @@ -155,13 +98,13 @@ describe('findChatReferences', () => { 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]]', + 'Open [[record:view:44444444-4444-4444-4444-444444444444:Quarterly]]', ), ).toEqual([ { kind: 'record', fullMatch: - '[[record:view:44444444-4444-4444-4444-444444444444:Quarterly[[/record]]', + '[[record:view:44444444-4444-4444-4444-444444444444:Quarterly]]', index: 5, objectNameSingular: 'view', recordId: '44444444-4444-4444-4444-444444444444', @@ -170,48 +113,16 @@ describe('findChatReferences', () => { ]); }); - 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]]', + '[[record:company2:a1b2c3d4-e5f6-7890-abcd-ef1234567890:Acme]] and [[object:company2:Companies 2]]', ), ).toEqual([ { kind: 'record', fullMatch: - '[[record:company2:a1b2c3d4-e5f6-7890-abcd-ef1234567890:Acme[[/record]]', + '[[record:company2:a1b2c3d4-e5f6-7890-abcd-ef1234567890:Acme]]', index: 0, objectNameSingular: 'company2', recordId: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890', @@ -219,8 +130,8 @@ describe('findChatReferences', () => { }, { kind: 'object', - fullMatch: '[[object:company2:Companies 2[[/object]]', - index: 75, + fullMatch: '[[object:company2:Companies 2]]', + index: 66, objectNameSingular: 'company2', displayName: 'Companies 2', }, @@ -229,7 +140,7 @@ describe('findChatReferences', () => { 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]]', + 'The [[view:44444444-4444-4444-4444-444444444444:Pipeline]] view of [[object:partner:Partners]] groups [[record:person:11111111-1111-1111-1111-111111111111:Alice]] by [[field:33333333-3333-3333-3333-333333333333:Stage]]', ); expect(references.map((reference) => reference.kind)).toEqual([ @@ -248,28 +159,80 @@ describe('findChatReferences', () => { it('should find adjacent references without a separator', () => { const references = findChatReferences( - '[[object:partner:Partners[[/object]][[object:company:Companies[[/object]]', + '[[object:partner:Partners]][[object:company:Companies]]', ); expect(references).toHaveLength(2); expect(references[0].displayName).toBe('Partners'); expect(references[1].displayName).toBe('Companies'); - expect(references[1].index).toBe(36); + expect(references[1].index).toBe(27); + }); + + it('should find a reference with an empty display name', () => { + expect( + findChatReferences( + '[[record:person:11111111-1111-1111-1111-111111111111:]]', + ), + ).toEqual([ + { + kind: 'record', + fullMatch: '[[record:person:11111111-1111-1111-1111-111111111111:]]', + index: 0, + objectNameSingular: 'person', + recordId: '11111111-1111-1111-1111-111111111111', + displayName: '', + }, + ]); }); it('should drop an unclosed reference', () => { expect(findChatReferences('Open [[object:partner:Partners')).toEqual([]); }); - it('should consume a surplus bracket added after the closing tag', () => { + it('should not treat a single bracket as a terminator', () => { + expect( + findChatReferences('Open [[object:partner:Partners] to start'), + ).toEqual([]); + }); + + it('should not let an unclosed reference swallow the next one', () => { + const references = findChatReferences( + 'Open [[object:partner:Partners [[object:company:Companies]]', + ); + + expect(references).toHaveLength(1); + expect(references[0].displayName).toBe('Companies'); + }); + + it('should not close a reference across a line break', () => { + expect( + findChatReferences('Open [[object:partner:Partners\nand others]]'), + ).toEqual([]); + }); + + it('should drop a reference whose display name contains brackets', () => { expect( findChatReferences( - 'Created [[object:opportunity:Opportunities[[/object]]].', + 'Open [[record:company:a1b2c3d4-e5f6-7890-abcd-ef1234567890:[test] name]]', ), + ).toEqual([]); + }); + + it('should drop a reference closed by a retired closing tag', () => { + expect( + findChatReferences( + 'Contact [[record:company:a1b2c3d4-e5f6-7890-abcd-ef1234567890:Acme[[/record]] and [[object:partner:Partners[[/object]] and [[field:33333333-3333-3333-3333-333333333333:Stage[[/field]] and [[view:44444444-4444-4444-4444-444444444444:Pipeline[[/view]]', + ), + ).toEqual([]); + }); + + it('should leave a surplus bracket added after a reference out of the match', () => { + expect( + findChatReferences('Created [[object:opportunity:Opportunities]]].'), ).toEqual([ { kind: 'object', - fullMatch: '[[object:opportunity:Opportunities[[/object]]]', + fullMatch: '[[object:opportunity:Opportunities]]', index: 8, objectNameSingular: 'opportunity', displayName: 'Opportunities', @@ -277,30 +240,38 @@ describe('findChatReferences', () => { ]); }); - it('should consume the extra brackets of an over-wrapped reference', () => { + it('should find a reference whose display name contains markdown characters', () => { expect( - findChatReferences('Created [[[object:partner:Partners[[/object]]]] now'), + findChatReferences( + 'See [[record:workflow:a1b2c3d4-e5f6-7890-abcd-ef1234567890:Workflow `UPDATE_RECORD` step]]', + ), ).toEqual([ { - kind: 'object', - fullMatch: '[[[object:partner:Partners[[/object]]]]', - index: 8, - objectNameSingular: 'partner', - displayName: 'Partners', + kind: 'record', + fullMatch: + '[[record:workflow:a1b2c3d4-e5f6-7890-abcd-ef1234567890:Workflow `UPDATE_RECORD` step]]', + index: 4, + objectNameSingular: 'workflow', + recordId: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890', + displayName: 'Workflow `UPDATE_RECORD` step', }, ]); }); - it('should not consume a bracket separated from the closing tag', () => { + it('should find a reference whose display name contains a colon', () => { expect( - findChatReferences('Created [[object:partner:Partners[[/object]] ] now'), + findChatReferences( + 'Ping [[record:person:a1b2c3d4-e5f6-7890-abcd-ef1234567890:Doe: Jane]]', + ), ).toEqual([ { - kind: 'object', - fullMatch: '[[object:partner:Partners[[/object]]', - index: 8, - objectNameSingular: 'partner', - displayName: 'Partners', + kind: 'record', + fullMatch: + '[[record:person:a1b2c3d4-e5f6-7890-abcd-ef1234567890:Doe: Jane]]', + index: 5, + objectNameSingular: 'person', + recordId: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890', + displayName: 'Doe: Jane', }, ]); }); diff --git a/packages/twenty-front/src/modules/ai/utils/__tests__/formatRecordReference.test.ts b/packages/twenty-front/src/modules/ai/utils/__tests__/formatRecordReference.test.ts index 1caae8b38d..2ab63543de 100644 --- a/packages/twenty-front/src/modules/ai/utils/__tests__/formatRecordReference.test.ts +++ b/packages/twenty-front/src/modules/ai/utils/__tests__/formatRecordReference.test.ts @@ -1,15 +1,13 @@ import { formatRecordReference } from '@/ai/utils/formatRecordReference'; describe('formatRecordReference', () => { - it('should use the [[/record]] close tag', () => { + it('should close the reference with ]]', () => { expect( formatRecordReference({ objectNameSingular: 'company', recordId: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890', - displayName: '[test] ]] [test]', + displayName: 'Acme', }), - ).toBe( - '[[record:company:a1b2c3d4-e5f6-7890-abcd-ef1234567890:[test] ]] [test][[/record]]', - ); + ).toBe('[[record:company:a1b2c3d4-e5f6-7890-abcd-ef1234567890:Acme]]'); }); }); diff --git a/packages/twenty-front/src/modules/ai/utils/__tests__/getMarkdownBlocksIncrementally.test.ts b/packages/twenty-front/src/modules/ai/utils/__tests__/getMarkdownBlocksIncrementally.test.ts index a6928ab13e..f4c75b9190 100644 --- a/packages/twenty-front/src/modules/ai/utils/__tests__/getMarkdownBlocksIncrementally.test.ts +++ b/packages/twenty-front/src/modules/ai/utils/__tests__/getMarkdownBlocksIncrementally.test.ts @@ -38,7 +38,7 @@ const FIXTURES: Record = { '# H1\n\ntext\n\n## H2\n\n- list\n- items\n\n> quote\n> more quote\n\nend', blockquotes: '> a\n\n> b\n\ntext', chatReferences: - 'Check [[field:12345678-1234-5678-abcd-123456789012:Annual Revenue[[/field]] and\n\n- [[view:12345678-1234-5678-abcd-123456789012:All[[/view]]\n\n| [[object:company:Companies[[/object]] | x |\n|---|---|\n| a | b |', + 'Check [[field:12345678-1234-5678-abcd-123456789012:Annual Revenue]] and\n\n- [[view:12345678-1234-5678-abcd-123456789012:All]]\n\n| [[object:company:Companies]] | x |\n|---|---|\n| a | b |', windowsLineEndings: 'line one\r\n\r\n- a\r\n\r\n- b\r\n\r\nend', }; diff --git a/packages/twenty-front/src/modules/ai/utils/__tests__/getSurplusCloseBracketLength.test.ts b/packages/twenty-front/src/modules/ai/utils/__tests__/getSurplusCloseBracketLength.test.ts deleted file mode 100644 index 641d73f0d6..0000000000 --- a/packages/twenty-front/src/modules/ai/utils/__tests__/getSurplusCloseBracketLength.test.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { getSurplusCloseBracketLength } from '@/ai/utils/getSurplusCloseBracketLength'; - -describe('getSurplusCloseBracketLength', () => { - it('should return zero when the closing tag is not followed by a bracket', () => { - expect( - getSurplusCloseBracketLength({ - textAfterClosing: ' next', - openBracketLength: 2, - }), - ).toBe(0); - }); - - it('should return zero when a bracket is separated from the closing tag', () => { - expect( - getSurplusCloseBracketLength({ - textAfterClosing: ' ] next', - openBracketLength: 2, - }), - ).toBe(0); - }); - - it('should count the brackets following the closing tag', () => { - expect( - getSurplusCloseBracketLength({ - textAfterClosing: ']. next', - openBracketLength: 2, - }), - ).toBe(1); - }); - - it('should count no more brackets than the reference opened with', () => { - expect( - getSurplusCloseBracketLength({ - textAfterClosing: ']]] next', - openBracketLength: 2, - }), - ).toBe(2); - - expect( - getSurplusCloseBracketLength({ - textAfterClosing: ']]] next', - openBracketLength: 3, - }), - ).toBe(3); - }); -}); 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 index cb521374fc..ea18219872 100644 --- a/packages/twenty-front/src/modules/ai/utils/__tests__/protectChatReferencesForMarkdown.test.ts +++ b/packages/twenty-front/src/modules/ai/utils/__tests__/protectChatReferencesForMarkdown.test.ts @@ -7,97 +7,107 @@ describe('protectChatReferencesForMarkdown', () => { ).toBe('Which company should we contact?'); }); - it('should rewrite legacy refs to the tagged format', () => { + it('should leave a reference with a plain label unchanged', () => { expect( protectChatReferencesForMarkdown( 'Contact [[record:company:a1b2c3d4-e5f6-7890-abcd-ef1234567890:Acme]] next', ), ).toBe( - 'Contact [[record:company:a1b2c3d4-e5f6-7890-abcd-ef1234567890:Acme[[/record]] next', + 'Contact [[record:company:a1b2c3d4-e5f6-7890-abcd-ef1234567890:Acme]] next', ); }); - it('should escape backticks in labels and close with the tag', () => { + it('should add the record prefix to a reference written without it', () => { expect( protectChatReferencesForMarkdown( - 'See [[record:workflow:a1b2c3d4-e5f6-7890-abcd-ef1234567890:Workflow `UPDATE_RECORD` step[[/record]]', + 'Contact [[company:a1b2c3d4-e5f6-7890-abcd-ef1234567890:Acme]] next', ), ).toBe( - 'See [[record:workflow:a1b2c3d4-e5f6-7890-abcd-ef1234567890:Workflow \\`UPDATE\\_RECORD\\` step[[/record]]', + 'Contact [[record:company:a1b2c3d4-e5f6-7890-abcd-ef1234567890:Acme]] next', ); }); - it('should escape square brackets and ]] inside labels', () => { + it('should escape backticks in labels', () => { expect( protectChatReferencesForMarkdown( - 'Open [[record:company:a1b2c3d4-e5f6-7890-abcd-ef1234567890:[test] ]] [test] [test] ###[[/record]]', + 'See [[record:workflow:a1b2c3d4-e5f6-7890-abcd-ef1234567890:Workflow `UPDATE_RECORD` step]]', ), ).toBe( - 'Open [[record:company:a1b2c3d4-e5f6-7890-abcd-ef1234567890:\\[test\\] \\]\\] \\[test\\] \\[test\\] \\#\\#\\#[[/record]]', + 'See [[record:workflow:a1b2c3d4-e5f6-7890-abcd-ef1234567890:Workflow \\`UPDATE\\_RECORD\\` step]]', ); }); it('should leave colons in labels unchanged', () => { expect( protectChatReferencesForMarkdown( - 'Ping [[record:person:a1b2c3d4-e5f6-7890-abcd-ef1234567890:Doe: Jane[[/record]]', + 'Ping [[record:person:a1b2c3d4-e5f6-7890-abcd-ef1234567890:Doe: Jane]]', ), ).toBe( - 'Ping [[record:person:a1b2c3d4-e5f6-7890-abcd-ef1234567890:Doe: Jane[[/record]]', + 'Ping [[record:person:a1b2c3d4-e5f6-7890-abcd-ef1234567890:Doe: Jane]]', ); }); it('should escape an object label without touching its name', () => { expect( protectChatReferencesForMarkdown( - 'Open [[object:partner:Partners (EMEA)[[/object]]', + 'Open [[object:partner:Partners (EMEA)]]', ), - ).toBe('Open [[object:partner:Partners \\(EMEA\\)[[/object]]'); + ).toBe('Open [[object:partner:Partners \\(EMEA\\)]]'); }); it('should escape a field label without touching its id', () => { expect( protectChatReferencesForMarkdown( - 'The [[field:33333333-3333-3333-3333-333333333333:Next step[[/field]] field', + 'The [[field:33333333-3333-3333-3333-333333333333:Next step]] field', ), ).toBe( - 'The [[field:33333333-3333-3333-3333-333333333333:Next step[[/field]] field', + 'The [[field:33333333-3333-3333-3333-333333333333:Next step]] field', ); }); it('should escape a view label without touching its id', () => { expect( protectChatReferencesForMarkdown( - 'See [[view:44444444-4444-4444-4444-444444444444:Q1 - pipeline[[/view]]', + 'See [[view:44444444-4444-4444-4444-444444444444:Q1 - pipeline]]', + ), + ).toBe('See [[view:44444444-4444-4444-4444-444444444444:Q1 \\- pipeline]]'); + }); + + it('should leave a marker whose label contains brackets unchanged', () => { + expect( + protectChatReferencesForMarkdown( + 'Open [[record:company:a1b2c3d4-e5f6-7890-abcd-ef1234567890:[test] name]]', ), ).toBe( - 'See [[view:44444444-4444-4444-4444-444444444444:Q1 \\- pipeline[[/view]]', + 'Open [[record:company:a1b2c3d4-e5f6-7890-abcd-ef1234567890:[test] name]]', ); }); - it('should drop a surplus bracket added after the closing tag', () => { + it('should leave a marker using a retired closing tag unchanged', () => { expect( protectChatReferencesForMarkdown( - 'Created [[object:opportunity:Opportunities[[/object]]].', + 'The [[field:33333333-3333-3333-3333-333333333333:Stage[[/field]] field', ), - ).toBe('Created [[object:opportunity:Opportunities[[/object]].'); + ).toBe( + 'The [[field:33333333-3333-3333-3333-333333333333:Stage[[/field]] field', + ); }); - it('should drop the extra brackets of an over-wrapped reference', () => { + it('should keep a surplus bracket added after a reference', () => { expect( protectChatReferencesForMarkdown( - 'Created [[[object:opportunity:Opportunities[[/object]]]] now', + 'Created [[object:opportunity:Opportunities]]].', ), - ).toBe('Created [[object:opportunity:Opportunities[[/object]] now'); + ).toBe('Created [[object:opportunity:Opportunities]]].'); }); 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]]', + 'The [[view:44444444-4444-4444-4444-444444444444:Pipeline]] view of [[object:partner:Partners]] groups [[person:11111111-1111-1111-1111-111111111111:Alice]] by [[field:33333333-3333-3333-3333-333333333333:Stage]]', ), ).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]]', + 'The [[view:44444444-4444-4444-4444-444444444444:Pipeline]] view of [[object:partner:Partners]] groups [[record:person:11111111-1111-1111-1111-111111111111:Alice]] by [[field:33333333-3333-3333-3333-333333333333:Stage]]', ); }); }); diff --git a/packages/twenty-front/src/modules/ai/utils/findChatReferenceClosing.ts b/packages/twenty-front/src/modules/ai/utils/findChatReferenceClosing.ts deleted file mode 100644 index 25e7ee4650..0000000000 --- a/packages/twenty-front/src/modules/ai/utils/findChatReferenceClosing.ts +++ /dev/null @@ -1,44 +0,0 @@ -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 index 6deb3f0969..34cdb9f973 100644 --- a/packages/twenty-front/src/modules/ai/utils/findChatReferences.ts +++ b/packages/twenty-front/src/modules/ai/utils/findChatReferences.ts @@ -1,59 +1,13 @@ -import { CHAT_REFERENCE_START_REGEX } from '@/ai/constants/ChatReferenceStartRegex'; +import { CHAT_REFERENCE_REGEX } from '@/ai/constants/ChatReferenceRegex'; 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 { getSurplusCloseBracketLength } from '@/ai/utils/getSurplusCloseBracketLength'; -import { isDefined } from 'twenty-shared/utils'; +import { getChatReferenceMatchFromRegexMatch } from '@/ai/utils/getChatReferenceMatchFromRegexMatch'; export const findChatReferences = (text: string): ChatReferenceMatch[] => { if (!text.includes('[[')) { return []; } - 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 []; - } - - const closingEnd = closing.index + closing.length; - const surplusCloseBracketLength = getSurplusCloseBracketLength({ - textAfterClosing: displayNameWindow.slice(closingEnd), - openBracketLength: start.openBracketLength, - }); - - return [ - { - ...start.identity, - fullMatch: text.slice( - start.index, - displayNameStart + closingEnd + surplusCloseBracketLength, - ), - index: start.index, - displayName: displayNameWindow.slice(0, closing.index), - }, - ]; - }); + return [...text.matchAll(CHAT_REFERENCE_REGEX)].map((match) => + getChatReferenceMatchFromRegexMatch(match), + ); }; diff --git a/packages/twenty-front/src/modules/ai/utils/formatChatReference.ts b/packages/twenty-front/src/modules/ai/utils/formatChatReference.ts index debbb46bc6..9ce2613f79 100644 --- a/packages/twenty-front/src/modules/ai/utils/formatChatReference.ts +++ b/packages/twenty-front/src/modules/ai/utils/formatChatReference.ts @@ -1,5 +1,4 @@ import { type ChatReferenceIdentity } from '@/ai/types/ChatReferenceIdentity'; -import { getChatReferenceCloseTag } from '@/ai/utils/getChatReferenceCloseTag'; import { getChatReferenceIdentitySegment } from '@/ai/utils/getChatReferenceIdentitySegment'; import { formatRecordReference } from 'twenty-shared/ai'; @@ -10,5 +9,5 @@ export const formatChatReference = ( return formatRecordReference(reference); } - return `[[${reference.kind}:${getChatReferenceIdentitySegment(reference)}:${reference.displayName}${getChatReferenceCloseTag(reference.kind)}`; + return `[[${reference.kind}:${getChatReferenceIdentitySegment(reference)}:${reference.displayName}]]`; }; diff --git a/packages/twenty-front/src/modules/ai/utils/getChatReferenceCloseTag.ts b/packages/twenty-front/src/modules/ai/utils/getChatReferenceCloseTag.ts deleted file mode 100644 index 3eea347fac..0000000000 --- a/packages/twenty-front/src/modules/ai/utils/getChatReferenceCloseTag.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { type ChatReferenceKind } from '@/ai/types/ChatReferenceKind'; - -export const getChatReferenceCloseTag = (kind: ChatReferenceKind): string => - `[[/${kind}]]`; diff --git a/packages/twenty-front/src/modules/ai/utils/getChatReferenceMatchFromRegexMatch.ts b/packages/twenty-front/src/modules/ai/utils/getChatReferenceMatchFromRegexMatch.ts new file mode 100644 index 0000000000..7b0c7fa804 --- /dev/null +++ b/packages/twenty-front/src/modules/ai/utils/getChatReferenceMatchFromRegexMatch.ts @@ -0,0 +1,55 @@ +import { type ChatReferenceMatch } from '@/ai/types/ChatReferenceMatch'; +import { isDefined } from 'twenty-shared/utils'; + +export const getChatReferenceMatchFromRegexMatch = ( + match: RegExpExecArray, +): ChatReferenceMatch => { + const { + objectNameSingular, + objectLabel, + fieldMetadataItemId, + fieldLabel, + viewId, + viewLabel, + recordObjectNameSingular, + recordId, + recordLabel, + } = match.groups ?? {}; + + const position = { fullMatch: match[0], index: match.index }; + + if (isDefined(objectNameSingular)) { + return { + ...position, + kind: 'object', + objectNameSingular, + displayName: objectLabel, + }; + } + + if (isDefined(fieldMetadataItemId)) { + return { + ...position, + kind: 'field', + fieldMetadataItemId, + displayName: fieldLabel, + }; + } + + if (isDefined(viewId)) { + return { + ...position, + kind: 'view', + viewId, + displayName: viewLabel, + }; + } + + return { + ...position, + kind: 'record', + objectNameSingular: recordObjectNameSingular, + recordId, + displayName: recordLabel, + }; +}; diff --git a/packages/twenty-front/src/modules/ai/utils/getChatReferenceStartFromMatch.ts b/packages/twenty-front/src/modules/ai/utils/getChatReferenceStartFromMatch.ts deleted file mode 100644 index ce0f475314..0000000000 --- a/packages/twenty-front/src/modules/ai/utils/getChatReferenceStartFromMatch.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { type ChatReferenceStart } from '@/ai/types/ChatReferenceStart'; -import { isDefined } from 'twenty-shared/utils'; - -const OPEN_BRACKETS_REGEX = /^\[+/; - -export const getChatReferenceStartFromMatch = ( - match: RegExpExecArray, -): ChatReferenceStart => { - const { - objectNameSingular, - fieldMetadataItemId, - viewId, - recordObjectNameSingular, - recordId, - } = match.groups ?? {}; - - const openBracketsMatch = OPEN_BRACKETS_REGEX.exec(match[0]); - - const position = { - index: match.index, - prefixLength: match[0].length, - openBracketLength: isDefined(openBracketsMatch) - ? openBracketsMatch[0].length - : 0, - }; - - 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/getSurplusCloseBracketLength.ts b/packages/twenty-front/src/modules/ai/utils/getSurplusCloseBracketLength.ts deleted file mode 100644 index 3baa361d27..0000000000 --- a/packages/twenty-front/src/modules/ai/utils/getSurplusCloseBracketLength.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { isDefined } from 'twenty-shared/utils'; - -const SURPLUS_CLOSE_BRACKETS_REGEX = /^\]+/; - -export const getSurplusCloseBracketLength = ({ - textAfterClosing, - openBracketLength, -}: { - textAfterClosing: string; - openBracketLength: number; -}): number => { - const surplusMatch = SURPLUS_CLOSE_BRACKETS_REGEX.exec(textAfterClosing); - - if (!isDefined(surplusMatch)) { - return 0; - } - - return Math.min(surplusMatch[0].length, openBracketLength); -}; diff --git a/packages/twenty-front/src/modules/mention/extensions/__tests__/MentionSuggestion.test.ts b/packages/twenty-front/src/modules/mention/extensions/__tests__/MentionSuggestion.test.ts index af973af4aa..25afe3fc8b 100644 --- a/packages/twenty-front/src/modules/mention/extensions/__tests__/MentionSuggestion.test.ts +++ b/packages/twenty-front/src/modules/mention/extensions/__tests__/MentionSuggestion.test.ts @@ -82,7 +82,7 @@ describe('MentionSuggestion', () => { const text = editor.getText(); - expect(text).toContain('[[record:company:test-id:Acme[[/record]]'); + expect(text).toContain('[[record:company:test-id:Acme]]'); }); it('should accept @ character in editor content', () => { diff --git a/packages/twenty-front/src/modules/mention/extensions/__tests__/MentionTag.test.ts b/packages/twenty-front/src/modules/mention/extensions/__tests__/MentionTag.test.ts index 5214eb1cce..6ab95dffbb 100644 --- a/packages/twenty-front/src/modules/mention/extensions/__tests__/MentionTag.test.ts +++ b/packages/twenty-front/src/modules/mention/extensions/__tests__/MentionTag.test.ts @@ -67,9 +67,7 @@ describe('MentionTag', () => { const text = editor.getText(); - expect(text).toBe( - 'Hello [[record:company:abc-123:Acme Corp[[/record]] world', - ); + expect(text).toBe('Hello [[record:company:abc-123:Acme Corp]] world'); }); it('should handle mentions with empty label', () => { @@ -95,7 +93,7 @@ describe('MentionTag', () => { const text = editor.getText(); - expect(text).toBe('[[record:person:id-456:[[/record]]'); + expect(text).toBe('[[record:person:id-456:]]'); }); it('should serialize multiple mentions in the same paragraph', () => { @@ -132,7 +130,7 @@ describe('MentionTag', () => { const text = editor.getText(); expect(text).toBe( - '[[record:person:r1:Alice[[/record]] and [[record:company:r2:Beta Inc[[/record]]', + '[[record:person:r1:Alice]] and [[record:company:r2:Beta Inc]]', ); }); }); @@ -158,9 +156,7 @@ describe('MentionTag', () => { const text = editor.getText(); - expect(text).toContain( - '[[record:opportunity:test-id:Big Deal[[/record]]', - ); + expect(text).toContain('[[record:opportunity:test-id:Big Deal]]'); }); }); 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 b92d0e0c2d..cc166db496 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 @@ -79,8 +79,8 @@ Format responses with markdown for clarity (headings, lists, code blocks, tables Record References - IMPORTANT: - Tool responses include a "recordReferences" array with clickable links - ONLY use record references that are returned by tools - NEVER make up IDs -- Copy the exact format from the tool response: [[record:objectName:recordId:displayName[[/record]] -- Example: [[record:company:abc12345-1234-5678-abcd-123456789012:Acme Corp[[/record]] +- Copy the exact format from the tool response: [[record:objectName:recordId:displayName]] +- Example: [[record:company:abc12345-1234-5678-abcd-123456789012:Acme Corp]] - Use record references only in paragraphs, lists, or markdown tables (\`| ... |\`); never in headings, code, links, or raw HTML - The recordId MUST be a real UUID (like "abc12345-1234-5678-abcd-123456789012") - DO NOT create record references before calling the tool @@ -90,20 +90,20 @@ Record References - IMPORTANT: 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]] +- Object: [[object:objectNameSingular:displayName]] + - Example: [[object:company:Companies]] - 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]] +- Field: [[field:fieldMetadataId:displayName]] + - Example: [[field:abc12345-1234-5678-abcd-123456789012:Annual Recurring Revenue]] - 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]] +- View: [[view:viewId:displayName]] + - Example: [[view:abc12345-1234-5678-abcd-123456789012:All Companies]] - 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 +- The displayName must stay on a single line and must not contain \`[\` or \`]\` - leave those characters out if a name includes them - 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 -- A reference is complete as written: never wrap it in extra square brackets, and never add \`]\` or \`]]\` after its closing tag +- A reference ends with the \`]]\` right after the displayName: never wrap it in extra square brackets, and never add \`]\` or \`]]\` after it - Use metadata references only in paragraphs, lists, or markdown tables (\`| ... |\`); never in headings, code, links, or raw HTML`, }; diff --git a/packages/twenty-shared/src/ai/utils/__tests__/format-record-reference.util.spec.ts b/packages/twenty-shared/src/ai/utils/__tests__/format-record-reference.util.spec.ts index ca99a3c31d..3dddcbcb22 100644 --- a/packages/twenty-shared/src/ai/utils/__tests__/format-record-reference.util.spec.ts +++ b/packages/twenty-shared/src/ai/utils/__tests__/format-record-reference.util.spec.ts @@ -6,8 +6,8 @@ describe('formatRecordReference', () => { formatRecordReference({ objectNameSingular: 'company', recordId: 'record-id', - displayName: '[test] ]] [test]', + displayName: 'Acme Corp', }), - ).toBe('[[record:company:record-id:[test] ]] [test][[/record]]'); + ).toBe('[[record:company:record-id:Acme Corp]]'); }); }); diff --git a/packages/twenty-shared/src/ai/utils/format-record-reference.util.ts b/packages/twenty-shared/src/ai/utils/format-record-reference.util.ts index 84adc538a7..710ffe00e8 100644 --- a/packages/twenty-shared/src/ai/utils/format-record-reference.util.ts +++ b/packages/twenty-shared/src/ai/utils/format-record-reference.util.ts @@ -6,5 +6,4 @@ export const formatRecordReference = ({ objectNameSingular: string; recordId: string; displayName: string; -}): string => - `[[record:${objectNameSingular}:${recordId}:${displayName}[[/record]]`; +}): string => `[[record:${objectNameSingular}:${recordId}:${displayName}]]`; diff --git a/packages/twenty-shared/src/utils/tiptap/__tests__/tiptap-document.test.ts b/packages/twenty-shared/src/utils/tiptap/__tests__/tiptap-document.test.ts index c031c32462..ba534ea77e 100644 --- a/packages/twenty-shared/src/utils/tiptap/__tests__/tiptap-document.test.ts +++ b/packages/twenty-shared/src/utils/tiptap/__tests__/tiptap-document.test.ts @@ -112,7 +112,7 @@ describe('TipTap document primitives', () => { [ '## Overview', '', - 'Hello [**Ada**](https://example.com) [[record:person:person-id:Ada Lovelace[[/record]]', + 'Hello [**Ada**](https://example.com) [[record:person:person-id:Ada Lovelace]]', '', '- First', '- Second',