diff --git a/packages/twenty-front/src/modules/ai/components/LazyMarkdownRenderer.tsx b/packages/twenty-front/src/modules/ai/components/LazyMarkdownRenderer.tsx index c4b87dd8e9..9c9036b0d5 100644 --- a/packages/twenty-front/src/modules/ai/components/LazyMarkdownRenderer.tsx +++ b/packages/twenty-front/src/modules/ai/components/LazyMarkdownRenderer.tsx @@ -7,6 +7,7 @@ import { } from '@/ai/components/LazyMarkdownRendererStyledComponents'; import { MarkdownCodeBlock } from '@/ai/components/MarkdownCodeBlock'; import { TextWithRecordLinks } from '@/ai/components/TextWithRecordLinks'; +import { protectRecordReferencesForMarkdown } from '@/ai/utils/protectRecordReferencesForMarkdown'; import { marked } from 'marked'; import { cloneElement, @@ -179,11 +180,16 @@ const MemoizedMarkdownBlock = memo( ); export const LazyMarkdownRenderer = ({ text }: { text: string }) => { - const markdownBlocks = useMemo( - () => marked.lexer(text).map((token) => token.raw), + const protectedText = useMemo( + () => protectRecordReferencesForMarkdown(text), [text], ); + const markdownBlocks = useMemo( + () => marked.lexer(protectedText).map((token) => token.raw), + [protectedText], + ); + return ( ); }; - -export const RECORD_REFERENCE_REGEX = - /\[\[(?:record:)?([a-zA-Z]+):([a-f0-9-]+):([^\]]+)\]\]/g; - -export const parseRecordReference = (match: string) => { - const regex = /\[\[(?:record:)?([a-zA-Z]+):([a-f0-9-]+):([^\]]+)\]\]/; - const result = regex.exec(match); - - if (!result) { - return null; - } - - return { - objectNameSingular: result[1], - recordId: result[2], - displayName: result[3], - }; -}; diff --git a/packages/twenty-front/src/modules/ai/components/TextWithRecordLinks.tsx b/packages/twenty-front/src/modules/ai/components/TextWithRecordLinks.tsx index 9deb84ee00..bb16558fea 100644 --- a/packages/twenty-front/src/modules/ai/components/TextWithRecordLinks.tsx +++ b/packages/twenty-front/src/modules/ai/components/TextWithRecordLinks.tsx @@ -1,42 +1,36 @@ -import { - parseRecordReference, - RECORD_REFERENCE_REGEX, - RecordLink, -} from '@/ai/components/RecordLink'; +import { RecordLink } from '@/ai/components/RecordLink'; +import { findRecordReferences } from '@/ai/utils/findRecordReferences'; import { type ReactNode } from 'react'; -import { isDefined } from 'twenty-shared/utils'; type TextWithRecordLinksProps = { text: string; }; export const TextWithRecordLinks = ({ text }: TextWithRecordLinksProps) => { + const references = findRecordReferences(text); + + if (references.length === 0) { + return <>{text}; + } + const parts: ReactNode[] = []; let lastIndex = 0; - RECORD_REFERENCE_REGEX.lastIndex = 0; - - let match; - - while ((match = RECORD_REFERENCE_REGEX.exec(text)) !== null) { - if (match.index > lastIndex) { - parts.push(text.slice(lastIndex, match.index)); + for (const reference of references) { + if (reference.index > lastIndex) { + parts.push(text.slice(lastIndex, reference.index)); } - const parsed = parseRecordReference(match[0]); + parts.push( + , + ); - if (isDefined(parsed)) { - parts.push( - , - ); - } - - lastIndex = match.index + match[0].length; + lastIndex = reference.index + reference.fullMatch.length; } if (lastIndex < text.length) { diff --git a/packages/twenty-front/src/modules/ai/components/__tests__/TextWithRecordLinks.test.tsx b/packages/twenty-front/src/modules/ai/components/__tests__/TextWithRecordLinks.test.tsx index 3789fbbe72..ca6a9a837c 100644 --- a/packages/twenty-front/src/modules/ai/components/__tests__/TextWithRecordLinks.test.tsx +++ b/packages/twenty-front/src/modules/ai/components/__tests__/TextWithRecordLinks.test.tsx @@ -3,22 +3,6 @@ import { render, screen } from '@testing-library/react'; import { TextWithRecordLinks } from '@/ai/components/TextWithRecordLinks'; jest.mock('@/ai/components/RecordLink', () => ({ - RECORD_REFERENCE_REGEX: - /\[\[(?:record:)?([a-zA-Z]+):([a-f0-9-]+):([^\]]+)\]\]/g, - parseRecordReference: (match: string) => { - const regex = /\[\[(?:record:)?([a-zA-Z]+):([a-f0-9-]+):([^\]]+)\]\]/; - const result = regex.exec(match); - - if (!result) { - return null; - } - - return { - objectNameSingular: result[1], - recordId: result[2], - displayName: result[3], - }; - }, RecordLink: ({ displayName, objectNameSingular, @@ -44,9 +28,9 @@ describe('TextWithRecordLinks', () => { expect(screen.queryByTestId('record-link')).not.toBeInTheDocument(); }); - it('should replace record references with RecordLink chips', () => { + it('should replace tagged record references with RecordLink chips', () => { render( - , + , ); expect(screen.getByTestId('record-link')).toHaveTextContent('Acme'); @@ -54,9 +38,18 @@ describe('TextWithRecordLinks', () => { 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'); @@ -66,4 +59,18 @@ describe('TextWithRecordLinks', () => { expect(recordLinks[1]).toHaveTextContent('Bob'); expect(screen.queryByText(/\[\[/)).not.toBeInTheDocument(); }); + + it('should chip tagged labels that contain backticks, brackets, colons, and ]]', () => { + render( + , + ); + + const recordLinks = screen.getAllByTestId('record-link'); + + expect(recordLinks).toHaveLength(3); + expect(recordLinks[0]).toHaveTextContent('Workflow `UPDATE_RECORD` step'); + expect(recordLinks[1]).toHaveTextContent('[test] ]] [test] [test] ###'); + expect(recordLinks[2]).toHaveTextContent('Doe: Jane'); + expect(screen.queryByText(/\[\[record:/)).not.toBeInTheDocument(); + }); }); diff --git a/packages/twenty-front/src/modules/ai/constants/RecordReferenceCloseTag.ts b/packages/twenty-front/src/modules/ai/constants/RecordReferenceCloseTag.ts new file mode 100644 index 0000000000..80c87936f4 --- /dev/null +++ b/packages/twenty-front/src/modules/ai/constants/RecordReferenceCloseTag.ts @@ -0,0 +1 @@ +export const RECORD_REFERENCE_CLOSE_TAG = '[[/record]]'; diff --git a/packages/twenty-front/src/modules/ai/types/RecordReferenceMatch.ts b/packages/twenty-front/src/modules/ai/types/RecordReferenceMatch.ts new file mode 100644 index 0000000000..89ad453af3 --- /dev/null +++ b/packages/twenty-front/src/modules/ai/types/RecordReferenceMatch.ts @@ -0,0 +1,7 @@ +export type RecordReferenceMatch = { + fullMatch: string; + index: number; + objectNameSingular: string; + recordId: string; + displayName: string; +}; diff --git a/packages/twenty-front/src/modules/ai/utils/__tests__/findRecordReferences.test.ts b/packages/twenty-front/src/modules/ai/utils/__tests__/findRecordReferences.test.ts new file mode 100644 index 0000000000..f524aa9c2a --- /dev/null +++ b/packages/twenty-front/src/modules/ai/utils/__tests__/findRecordReferences.test.ts @@ -0,0 +1,102 @@ +import { findRecordReferences } from '@/ai/utils/findRecordReferences'; + +describe('findRecordReferences', () => { + it('should leave plain text without matches', () => { + expect(findRecordReferences('Which company should we contact?')).toEqual( + [], + ); + }); + + it('should find a tagged record reference', () => { + expect( + findRecordReferences( + 'Contact [[record:company:a1b2c3d4-e5f6-7890-abcd-ef1234567890:Acme[[/record]] next', + ), + ).toEqual([ + { + fullMatch: + '[[record:company:a1b2c3d4-e5f6-7890-abcd-ef1234567890:Acme[[/record]]', + index: 8, + objectNameSingular: 'company', + recordId: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890', + displayName: 'Acme', + }, + ]); + }); + + it('should include ]] inside a tagged display name', () => { + expect( + findRecordReferences( + 'The company is [[record:company:a1b2c3d4-e5f6-7890-abcd-ef1234567890:[test] ]] [test] [test] ###[[/record]], created on July 21', + ), + ).toEqual([ + { + fullMatch: + '[[record:company:a1b2c3d4-e5f6-7890-abcd-ef1234567890:[test] ]] [test] [test] ###[[/record]]', + index: 15, + objectNameSingular: 'company', + recordId: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890', + displayName: '[test] ]] [test] [test] ###', + }, + ]); + }); + + it('should still support legacy ]] terminators', () => { + expect( + findRecordReferences( + 'Contact [[record:company:a1b2c3d4-e5f6-7890-abcd-ef1234567890:Acme]] next', + ), + ).toEqual([ + { + fullMatch: + '[[record:company:a1b2c3d4-e5f6-7890-abcd-ef1234567890:Acme]]', + index: 8, + objectNameSingular: 'company', + recordId: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890', + displayName: 'Acme', + }, + ]); + }); + + it('should still support legacy ]] inside display names', () => { + expect( + findRecordReferences( + 'The company is [[record:company:a1b2c3d4-e5f6-7890-abcd-ef1234567890:[test] ]] [test] [test] ###]], created on July 21', + ), + ).toEqual([ + { + fullMatch: + '[[record:company:a1b2c3d4-e5f6-7890-abcd-ef1234567890:[test] ]] [test] [test] ###]]', + index: 15, + objectNameSingular: 'company', + recordId: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890', + displayName: '[test] ]] [test] [test] ###', + }, + ]); + }); + + it('should find multiple tagged references without consuming into the next one', () => { + expect( + findRecordReferences( + 'Merge [[person:11111111-1111-1111-1111-111111111111:Alice[[/record]] into [[record:person:22222222-2222-2222-2222-222222222222:Bob[[/record]]', + ), + ).toEqual([ + { + fullMatch: + '[[person:11111111-1111-1111-1111-111111111111:Alice[[/record]]', + index: 6, + objectNameSingular: 'person', + recordId: '11111111-1111-1111-1111-111111111111', + displayName: 'Alice', + }, + { + fullMatch: + '[[record:person:22222222-2222-2222-2222-222222222222:Bob[[/record]]', + index: 74, + objectNameSingular: 'person', + recordId: '22222222-2222-2222-2222-222222222222', + displayName: 'Bob', + }, + ]); + }); +}); diff --git a/packages/twenty-front/src/modules/ai/utils/__tests__/formatRecordReference.test.ts b/packages/twenty-front/src/modules/ai/utils/__tests__/formatRecordReference.test.ts new file mode 100644 index 0000000000..1caae8b38d --- /dev/null +++ b/packages/twenty-front/src/modules/ai/utils/__tests__/formatRecordReference.test.ts @@ -0,0 +1,15 @@ +import { formatRecordReference } from '@/ai/utils/formatRecordReference'; + +describe('formatRecordReference', () => { + it('should use the [[/record]] close tag', () => { + expect( + formatRecordReference({ + objectNameSingular: 'company', + recordId: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890', + displayName: '[test] ]] [test]', + }), + ).toBe( + '[[record:company:a1b2c3d4-e5f6-7890-abcd-ef1234567890:[test] ]] [test][[/record]]', + ); + }); +}); diff --git a/packages/twenty-front/src/modules/ai/utils/__tests__/protectRecordReferencesForMarkdown.test.ts b/packages/twenty-front/src/modules/ai/utils/__tests__/protectRecordReferencesForMarkdown.test.ts new file mode 100644 index 0000000000..f08435f3d1 --- /dev/null +++ b/packages/twenty-front/src/modules/ai/utils/__tests__/protectRecordReferencesForMarkdown.test.ts @@ -0,0 +1,49 @@ +import { protectRecordReferencesForMarkdown } from '@/ai/utils/protectRecordReferencesForMarkdown'; + +describe('protectRecordReferencesForMarkdown', () => { + it('should leave plain text unchanged', () => { + expect( + protectRecordReferencesForMarkdown('Which company should we contact?'), + ).toBe('Which company should we contact?'); + }); + + it('should rewrite legacy refs to the tagged format', () => { + expect( + protectRecordReferencesForMarkdown( + 'Contact [[record:company:a1b2c3d4-e5f6-7890-abcd-ef1234567890:Acme]] next', + ), + ).toBe( + 'Contact [[record:company:a1b2c3d4-e5f6-7890-abcd-ef1234567890:Acme[[/record]] next', + ); + }); + + it('should escape backticks in labels and close with the tag', () => { + expect( + protectRecordReferencesForMarkdown( + 'See [[record:workflow:a1b2c3d4-e5f6-7890-abcd-ef1234567890:Workflow `UPDATE_RECORD` step[[/record]]', + ), + ).toBe( + 'See [[record:workflow:a1b2c3d4-e5f6-7890-abcd-ef1234567890:Workflow \\`UPDATE\\_RECORD\\` step[[/record]]', + ); + }); + + it('should escape square brackets and ]] inside labels', () => { + expect( + protectRecordReferencesForMarkdown( + 'Open [[record:company:a1b2c3d4-e5f6-7890-abcd-ef1234567890:[test] ]] [test] [test] ###[[/record]]', + ), + ).toBe( + 'Open [[record:company:a1b2c3d4-e5f6-7890-abcd-ef1234567890:\\[test\\] \\]\\] \\[test\\] \\[test\\] \\#\\#\\#[[/record]]', + ); + }); + + it('should leave colons in labels unchanged', () => { + expect( + protectRecordReferencesForMarkdown( + 'Ping [[record:person:a1b2c3d4-e5f6-7890-abcd-ef1234567890:Doe: Jane[[/record]]', + ), + ).toBe( + 'Ping [[record:person:a1b2c3d4-e5f6-7890-abcd-ef1234567890:Doe: Jane[[/record]]', + ); + }); +}); diff --git a/packages/twenty-front/src/modules/ai/utils/findRecordReferences.ts b/packages/twenty-front/src/modules/ai/utils/findRecordReferences.ts new file mode 100644 index 0000000000..7465938e38 --- /dev/null +++ b/packages/twenty-front/src/modules/ai/utils/findRecordReferences.ts @@ -0,0 +1,62 @@ +import { RECORD_REFERENCE_CLOSE_TAG } from '@/ai/constants/RecordReferenceCloseTag'; +import { type RecordReferenceMatch } from '@/ai/types/RecordReferenceMatch'; + +const RECORD_REFERENCE_START_REGEX = + /\[\[(?:record:)?([a-zA-Z]+):([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}):/g; + +export const findRecordReferences = (text: string): RecordReferenceMatch[] => { + const starts: Array<{ + index: number; + prefixLength: number; + objectNameSingular: string; + recordId: string; + }> = []; + + RECORD_REFERENCE_START_REGEX.lastIndex = 0; + + let startMatch; + + while ((startMatch = RECORD_REFERENCE_START_REGEX.exec(text)) !== null) { + starts.push({ + index: startMatch.index, + prefixLength: startMatch[0].length, + objectNameSingular: startMatch[1], + recordId: startMatch[2], + }); + } + + return starts.flatMap((start, startIndex) => { + const displayNameStart = start.index + start.prefixLength; + const windowEnd = + startIndex + 1 < starts.length + ? starts[startIndex + 1].index + : text.length; + const window = text.slice(displayNameStart, windowEnd); + + const closeTagIndexInWindow = window.indexOf(RECORD_REFERENCE_CLOSE_TAG); + + const closingIndexInWindow = + closeTagIndexInWindow !== -1 + ? closeTagIndexInWindow + : window.lastIndexOf(']]'); + + if (closingIndexInWindow === -1) { + return []; + } + + const closeLength = + closeTagIndexInWindow !== -1 ? RECORD_REFERENCE_CLOSE_TAG.length : 2; + const displayName = window.slice(0, closingIndexInWindow); + const fullMatchEnd = displayNameStart + closingIndexInWindow + closeLength; + + return [ + { + fullMatch: text.slice(start.index, fullMatchEnd), + index: start.index, + objectNameSingular: start.objectNameSingular, + recordId: start.recordId, + displayName, + }, + ]; + }); +}; diff --git a/packages/twenty-front/src/modules/ai/utils/formatRecordReference.ts b/packages/twenty-front/src/modules/ai/utils/formatRecordReference.ts new file mode 100644 index 0000000000..8ddcb49850 --- /dev/null +++ b/packages/twenty-front/src/modules/ai/utils/formatRecordReference.ts @@ -0,0 +1,12 @@ +import { RECORD_REFERENCE_CLOSE_TAG } from '@/ai/constants/RecordReferenceCloseTag'; + +export const formatRecordReference = ({ + objectNameSingular, + recordId, + displayName, +}: { + objectNameSingular: string; + recordId: string; + displayName: string; +}): string => + `[[record:${objectNameSingular}:${recordId}:${displayName}${RECORD_REFERENCE_CLOSE_TAG}`; diff --git a/packages/twenty-front/src/modules/ai/utils/protectRecordReferencesForMarkdown.ts b/packages/twenty-front/src/modules/ai/utils/protectRecordReferencesForMarkdown.ts new file mode 100644 index 0000000000..d9cbcabfca --- /dev/null +++ b/packages/twenty-front/src/modules/ai/utils/protectRecordReferencesForMarkdown.ts @@ -0,0 +1,26 @@ +import { findRecordReferences } from '@/ai/utils/findRecordReferences'; +import { formatRecordReference } from '@/ai/utils/formatRecordReference'; + +const escapeMarkdown = (text: string): string => + text.replace(/([\\`*_{}[\]()#+\-.!|~>])/g, '\\$1'); + +export const protectRecordReferencesForMarkdown = (text: string): string => { + const references = findRecordReferences(text); + let result = text; + + for (let index = references.length - 1; index >= 0; index--) { + const reference = references[index]; + const replacement = formatRecordReference({ + objectNameSingular: reference.objectNameSingular, + recordId: reference.recordId, + displayName: escapeMarkdown(reference.displayName), + }); + + result = + result.slice(0, reference.index) + + replacement + + result.slice(reference.index + reference.fullMatch.length); + } + + return result; +}; diff --git a/packages/twenty-front/src/modules/mention/extensions/MentionTag.ts b/packages/twenty-front/src/modules/mention/extensions/MentionTag.ts index 972dba3f7d..640a9ec169 100644 --- a/packages/twenty-front/src/modules/mention/extensions/MentionTag.ts +++ b/packages/twenty-front/src/modules/mention/extensions/MentionTag.ts @@ -1,3 +1,4 @@ +import { formatRecordReference } from '@/ai/utils/formatRecordReference'; import { MentionChip } from '@/mention/components/MentionChip'; import { Node } from '@tiptap/core'; import { mergeAttributes, ReactNodeViewRenderer } from '@tiptap/react'; @@ -57,6 +58,10 @@ export const MentionTag = Node.create({ renderText: ({ node }) => { const { objectNameSingular, recordId, label } = node.attrs; - return `[[record:${objectNameSingular}:${recordId}:${label}]]`; + return formatRecordReference({ + objectNameSingular, + recordId, + displayName: label, + }); }, }); 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 25afe3fc8b..af973af4aa 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]]'); + expect(text).toContain('[[record:company:test-id:Acme[[/record]]'); }); 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 6ab95dffbb..5214eb1cce 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,7 +67,9 @@ describe('MentionTag', () => { const text = editor.getText(); - expect(text).toBe('Hello [[record:company:abc-123:Acme Corp]] world'); + expect(text).toBe( + 'Hello [[record:company:abc-123:Acme Corp[[/record]] world', + ); }); it('should handle mentions with empty label', () => { @@ -93,7 +95,7 @@ describe('MentionTag', () => { const text = editor.getText(); - expect(text).toBe('[[record:person:id-456:]]'); + expect(text).toBe('[[record:person:id-456:[[/record]]'); }); it('should serialize multiple mentions in the same paragraph', () => { @@ -130,7 +132,7 @@ describe('MentionTag', () => { const text = editor.getText(); expect(text).toBe( - '[[record:person:r1:Alice]] and [[record:company:r2:Beta Inc]]', + '[[record:person:r1:Alice[[/record]] and [[record:company:r2:Beta Inc[[/record]]', ); }); }); @@ -156,7 +158,9 @@ describe('MentionTag', () => { const text = editor.getText(); - expect(text).toContain('[[record:opportunity:test-id:Big Deal]]'); + expect(text).toContain( + '[[record:opportunity:test-id:Big Deal[[/record]]', + ); }); }); 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 97cf026104..7e3d37994c 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,7 +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]] +- Copy the exact format from the tool response: [[record:objectName:recordId:displayName[[/record]] +- Example: [[record:company:abc12345-1234-5678-abcd-123456789012:Acme Corp[[/record]] - 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