From 829ef9d8b9cdfc4989c2dfcb30429fe25a291674 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Bosi?= <71827178+bosiraphael@users.noreply.github.com> Date: Thu, 6 Aug 2026 13:11:26 +0200 Subject: [PATCH] Revert AI chat chips to the [[kind:...:label]] syntax (#23852) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removes the `[[kind:...:label[[/kind]]` closing-tag syntax and goes back to the simpler `[[kind:...:label]]` form for all four chip kinds (record, object, field, view). The parser is now a single regex pass instead of a two-pass scan with a per-reference closing-tag search, a legacy fallback and surplus-bracket handling. That removes 11 files. The label pattern excludes `[`, `]` and newlines, which is what keeps an unclosed marker from swallowing the text (and the marker) that follows it. ```mermaid flowchart LR subgraph before ["Before — two passes"] O1["scan for marker openings"] --> O2["window each opening
up to the next one"] O2 --> O3["find that kind's closing tag
inside the window"] O3 --> O4["record only:
bare-terminator fallback"] O4 --> O5["consume surplus
closing brackets"] end subgraph after ["After — one pass"] N1["matchAll, one regex:
object · field · view · record"] --> N2["map each match
to a chip"] end before -.->|"11 files deleted"| after ``` Two things to know: - Messages already stored with closing tags render as raw text instead of chips. - Malformed model output is no longer compensated for: a surplus `]` after a chip stays in the text, and a display name containing brackets does not chip. The system prompt tells the model to avoid both. Rendering cost is unchanged for normal messages and noticeably lower on long bracket runs, since the old opening pattern had to scan them. Review in cubic --- .../__stories__/ChatReferenceChip.stories.tsx | 16 -- .../__tests__/TextWithChatReferences.test.tsx | 51 +--- .../AnyChatReferenceCloseTagRegex.ts | 5 - .../ai/constants/ChatReferenceFieldPattern.ts | 4 + .../ai/constants/ChatReferenceKinds.ts | 6 - .../ai/constants/ChatReferenceLabelPattern.ts | 2 + .../constants/ChatReferenceObjectPattern.ts | 4 + .../ai/constants/ChatReferenceOpenPattern.ts | 5 - .../constants/ChatReferenceRecordPattern.ts | 5 + .../ai/constants/ChatReferenceRegex.ts | 16 ++ .../ai/constants/ChatReferenceStartRegex.ts | 15 -- .../ai/constants/ChatReferenceViewPattern.ts | 4 + .../modules/ai/types/ChatReferenceClosing.ts | 4 - .../src/modules/ai/types/ChatReferenceKind.ts | 3 - .../modules/ai/types/ChatReferenceStart.ts | 8 - .../__tests__/findChatReferences.test.ts | 225 ++++++++---------- .../__tests__/formatRecordReference.test.ts | 8 +- .../getMarkdownBlocksIncrementally.test.ts | 2 +- .../getSurplusCloseBracketLength.test.ts | 46 ---- .../protectChatReferencesForMarkdown.test.ts | 58 +++-- .../ai/utils/findChatReferenceClosing.ts | 44 ---- .../modules/ai/utils/findChatReferences.ts | 56 +---- .../modules/ai/utils/formatChatReference.ts | 3 +- .../ai/utils/getChatReferenceCloseTag.ts | 4 - .../getChatReferenceMatchFromRegexMatch.ts | 55 +++++ .../utils/getChatReferenceStartFromMatch.ts | 47 ---- .../ai/utils/getSurplusCloseBracketLength.ts | 19 -- .../__tests__/MentionSuggestion.test.ts | 2 +- .../extensions/__tests__/MentionTag.test.ts | 12 +- .../constants/chat-system-prompts.const.ts | 20 +- .../format-record-reference.util.spec.ts | 4 +- .../ai/utils/format-record-reference.util.ts | 3 +- .../tiptap/__tests__/tiptap-document.test.ts | 2 +- 33 files changed, 264 insertions(+), 494 deletions(-) delete mode 100644 packages/twenty-front/src/modules/ai/constants/AnyChatReferenceCloseTagRegex.ts create mode 100644 packages/twenty-front/src/modules/ai/constants/ChatReferenceFieldPattern.ts delete mode 100644 packages/twenty-front/src/modules/ai/constants/ChatReferenceKinds.ts create mode 100644 packages/twenty-front/src/modules/ai/constants/ChatReferenceLabelPattern.ts create mode 100644 packages/twenty-front/src/modules/ai/constants/ChatReferenceObjectPattern.ts delete mode 100644 packages/twenty-front/src/modules/ai/constants/ChatReferenceOpenPattern.ts create mode 100644 packages/twenty-front/src/modules/ai/constants/ChatReferenceRecordPattern.ts create mode 100644 packages/twenty-front/src/modules/ai/constants/ChatReferenceRegex.ts delete mode 100644 packages/twenty-front/src/modules/ai/constants/ChatReferenceStartRegex.ts create mode 100644 packages/twenty-front/src/modules/ai/constants/ChatReferenceViewPattern.ts delete mode 100644 packages/twenty-front/src/modules/ai/types/ChatReferenceClosing.ts delete mode 100644 packages/twenty-front/src/modules/ai/types/ChatReferenceKind.ts delete mode 100644 packages/twenty-front/src/modules/ai/types/ChatReferenceStart.ts delete mode 100644 packages/twenty-front/src/modules/ai/utils/__tests__/getSurplusCloseBracketLength.test.ts delete mode 100644 packages/twenty-front/src/modules/ai/utils/findChatReferenceClosing.ts delete mode 100644 packages/twenty-front/src/modules/ai/utils/getChatReferenceCloseTag.ts create mode 100644 packages/twenty-front/src/modules/ai/utils/getChatReferenceMatchFromRegexMatch.ts delete mode 100644 packages/twenty-front/src/modules/ai/utils/getChatReferenceStartFromMatch.ts delete mode 100644 packages/twenty-front/src/modules/ai/utils/getSurplusCloseBracketLength.ts 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',