Revert AI chat chips to the [[kind:...:label]] syntax (#23852)
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<br/>up to the next one"]
O2 --> O3["find that kind's closing tag<br/>inside the window"]
O3 --> O4["record only:<br/>bare-terminator fallback"]
O4 --> O5["consume surplus<br/>closing brackets"]
end
subgraph after ["After — one pass"]
N1["matchAll, one regex:<br/>object · field · view · record"] --> N2["map each match<br/>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.
<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23852?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
This commit is contained in:
-16
@@ -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(
|
||||
|
||||
+13
-38
@@ -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(
|
||||
<TextWithChatReferences text="Contact [[record:company:a1b2c3d4-e5f6-7890-abcd-ef1234567890:Acme[[/record]] next" />,
|
||||
<TextWithChatReferences text="Contact [[record:company:a1b2c3d4-e5f6-7890-abcd-ef1234567890:Acme]] next" />,
|
||||
);
|
||||
|
||||
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(
|
||||
<TextWithChatReferences text="Contact [[record:company:a1b2c3d4-e5f6-7890-abcd-ef1234567890:Acme]] next" />,
|
||||
);
|
||||
|
||||
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(
|
||||
<TextWithChatReferences text="Merge [[person:11111111-1111-1111-1111-111111111111:Alice[[/record]] into [[person:22222222-2222-2222-2222-222222222222:Bob[[/record]]" />,
|
||||
<TextWithChatReferences text="Merge [[person:11111111-1111-1111-1111-111111111111:Alice]] into [[person:22222222-2222-2222-2222-222222222222:Bob]]" />,
|
||||
);
|
||||
|
||||
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(
|
||||
<TextWithChatReferences text="See [[record:workflow:a1b2c3d4-e5f6-7890-abcd-ef1234567890:Workflow `UPDATE_RECORD` step[[/record]] and [[record:company:b1b2c3d4-e5f6-7890-abcd-ef1234567890:[test] ]] [test] [test] ###[[/record]] then [[record:person:c1b2c3d4-e5f6-7890-abcd-ef1234567890:Doe: Jane[[/record]]" />,
|
||||
<TextWithChatReferences text="See [[record:workflow:a1b2c3d4-e5f6-7890-abcd-ef1234567890:Workflow `UPDATE_RECORD` step]] then [[record:person:c1b2c3d4-e5f6-7890-abcd-ef1234567890:Doe: Jane]]" />,
|
||||
);
|
||||
|
||||
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(
|
||||
<TextWithChatReferences text="Created [[object:opportunity:Opportunities[[/object]]]." />,
|
||||
<TextWithChatReferences text="Contact [[record:company:a1b2c3d4-e5f6-7890-abcd-ef1234567890:Acme[[/record]] next" />,
|
||||
);
|
||||
|
||||
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(
|
||||
<TextWithChatReferences text="Created [[[object:opportunity:Opportunities[[/object]]]] now" />,
|
||||
);
|
||||
|
||||
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(
|
||||
<TextWithChatReferences text="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]]" />,
|
||||
<TextWithChatReferences text="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(screen.getByTestId('view-link')).toHaveAttribute(
|
||||
|
||||
@@ -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('|')})\\]\\]`,
|
||||
);
|
||||
@@ -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:(?<fieldMetadataItemId>${CHAT_REFERENCE_UUID_PATTERN}):(?<fieldLabel>${CHAT_REFERENCE_LABEL_PATTERN})\\]\\]`;
|
||||
@@ -1,6 +0,0 @@
|
||||
export const CHAT_REFERENCE_KINDS = [
|
||||
'record',
|
||||
'object',
|
||||
'field',
|
||||
'view',
|
||||
] as const;
|
||||
@@ -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]*';
|
||||
@@ -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:(?<objectNameSingular>${CHAT_REFERENCE_METADATA_NAME_PATTERN}):(?<objectLabel>${CHAT_REFERENCE_LABEL_PATTERN})\\]\\]`;
|
||||
@@ -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 = '(?<!\\[)\\[\\[+';
|
||||
@@ -0,0 +1,5 @@
|
||||
import { CHAT_REFERENCE_LABEL_PATTERN } from '@/ai/constants/ChatReferenceLabelPattern';
|
||||
import { CHAT_REFERENCE_METADATA_NAME_PATTERN } from '@/ai/constants/ChatReferenceMetadataNamePattern';
|
||||
import { CHAT_REFERENCE_UUID_PATTERN } from '@/ai/constants/ChatReferenceUuidPattern';
|
||||
|
||||
export const CHAT_REFERENCE_RECORD_PATTERN = `\\[\\[(?:record:)?(?<recordObjectNameSingular>${CHAT_REFERENCE_METADATA_NAME_PATTERN}):(?<recordId>${CHAT_REFERENCE_UUID_PATTERN}):(?<recordLabel>${CHAT_REFERENCE_LABEL_PATTERN})\\]\\]`;
|
||||
@@ -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',
|
||||
);
|
||||
@@ -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:(?<objectNameSingular>${CHAT_REFERENCE_METADATA_NAME_PATTERN}):`,
|
||||
`${CHAT_REFERENCE_OPEN_PATTERN}field:(?<fieldMetadataItemId>${CHAT_REFERENCE_UUID_PATTERN}):`,
|
||||
`${CHAT_REFERENCE_OPEN_PATTERN}view:(?<viewId>${CHAT_REFERENCE_UUID_PATTERN}):`,
|
||||
`${CHAT_REFERENCE_OPEN_PATTERN}(?:record:)?(?<recordObjectNameSingular>${CHAT_REFERENCE_METADATA_NAME_PATTERN}):(?<recordId>${CHAT_REFERENCE_UUID_PATTERN}):`,
|
||||
].join('|'),
|
||||
'g',
|
||||
);
|
||||
@@ -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:(?<viewId>${CHAT_REFERENCE_UUID_PATTERN}):(?<viewLabel>${CHAT_REFERENCE_LABEL_PATTERN})\\]\\]`;
|
||||
@@ -1,4 +0,0 @@
|
||||
export type ChatReferenceClosing = {
|
||||
index: number;
|
||||
length: number;
|
||||
};
|
||||
@@ -1,3 +0,0 @@
|
||||
import { type CHAT_REFERENCE_KINDS } from '@/ai/constants/ChatReferenceKinds';
|
||||
|
||||
export type ChatReferenceKind = (typeof CHAT_REFERENCE_KINDS)[number];
|
||||
@@ -1,8 +0,0 @@
|
||||
import { type ChatReferenceIdentity } from '@/ai/types/ChatReferenceIdentity';
|
||||
|
||||
export type ChatReferenceStart = {
|
||||
index: number;
|
||||
prefixLength: number;
|
||||
openBracketLength: number;
|
||||
identity: ChatReferenceIdentity;
|
||||
};
|
||||
@@ -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',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -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]]');
|
||||
});
|
||||
});
|
||||
|
||||
+1
-1
@@ -38,7 +38,7 @@ const FIXTURES: Record<string, string> = {
|
||||
'# 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',
|
||||
};
|
||||
|
||||
|
||||
-46
@@ -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);
|
||||
});
|
||||
});
|
||||
+34
-24
@@ -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]]',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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),
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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}]]`;
|
||||
};
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
import { type ChatReferenceKind } from '@/ai/types/ChatReferenceKind';
|
||||
|
||||
export const getChatReferenceCloseTag = (kind: ChatReferenceKind): string =>
|
||||
`[[/${kind}]]`;
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -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);
|
||||
};
|
||||
+1
-1
@@ -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', () => {
|
||||
|
||||
@@ -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]]');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+10
-10
@@ -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`,
|
||||
};
|
||||
|
||||
@@ -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]]');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,5 +6,4 @@ export const formatRecordReference = ({
|
||||
objectNameSingular: string;
|
||||
recordId: string;
|
||||
displayName: string;
|
||||
}): string =>
|
||||
`[[record:${objectNameSingular}:${recordId}:${displayName}[[/record]]`;
|
||||
}): string => `[[record:${objectNameSingular}:${recordId}:${displayName}]]`;
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user