Add @mention support in AI Chat input (#17943)
## Summary
- Add `@mention` support to the AI Chat text input by replacing the
plain textarea with a minimal Tiptap editor and building a shared
`mention` module with reusable Tiptap extensions (`MentionTag`,
`MentionSuggestion`), search hook (`useMentionSearch`), and suggestion
menu — all shared with the existing BlockNote-based Notes mentions to
avoid code duplication
- Mentions are serialized as
`[[record:objectName:recordId:displayName]]` markdown (the format
already understood by the backend and rendered in chat messages), and
displayed using the existing `RecordLink` chip component for visual
consistency
- Fix images in chat messages overflowing their container by
constraining to `max-width: 100%`
- Fix web_search tool display showing literal `{query}` instead of the
actual query (ICU single-quote escaping issue in Lingui `t` tagged
templates)
## Test plan
- [ ] Open AI Chat, type `@` and verify the suggestion menu appears with
searchable records
- [ ] Select a mention from the dropdown (via click or keyboard
Enter/ArrowUp/Down) and verify the record chip renders inline
- [ ] Send a message containing a mention and verify it appears
correctly in the conversation as a clickable `RecordLink`
- [ ] Verify Enter sends the message when the suggestion menu is closed,
and selects a mention when the menu is open
- [ ] Verify images in AI chat responses are constrained to the
container width
- [ ] Verify the web_search tool step shows the actual search query
(e.g. "Searched the web for Salesforce") instead of `{query}`
- [ ] Verify Notes @mentions still work as before
Made with [Cursor](https://cursor.com)
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+75
-1
@@ -3,6 +3,7 @@ import { Injectable } from '@nestjs/common';
|
||||
import { type ToolSet } from 'ai';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { AggregateOperations } from 'src/engine/api/graphql/graphql-query-runner/constants/aggregate-operations.constant';
|
||||
import { formatValidationErrors } from 'src/engine/core-modules/tool-provider/utils/format-validation-errors.util';
|
||||
import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
|
||||
import { ViewType } from 'src/engine/metadata-modules/view/enums/view-type.enum';
|
||||
@@ -56,6 +57,22 @@ const CreateViewInputSchema = z.object({
|
||||
.optional()
|
||||
.default(ViewVisibility.WORKSPACE)
|
||||
.describe('View visibility'),
|
||||
mainGroupByFieldName: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
'Field name to group by (required for KANBAN views, must be a SELECT field, e.g., "stage", "status")',
|
||||
),
|
||||
kanbanAggregateOperation: z
|
||||
.enum(Object.values(AggregateOperations) as [string, ...string[]])
|
||||
.optional()
|
||||
.describe(
|
||||
'Aggregate operation for kanban columns (e.g., "SUM", "AVG", "COUNT")',
|
||||
),
|
||||
kanbanAggregateOperationFieldName: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Field name for the kanban aggregate operation (e.g., "amount")'),
|
||||
});
|
||||
|
||||
const UpdateViewInputSchema = z.object({
|
||||
@@ -101,6 +118,36 @@ export class ViewToolsFactory {
|
||||
return objectMetadata.id;
|
||||
}
|
||||
|
||||
private async resolveFieldMetadataId(
|
||||
workspaceId: string,
|
||||
objectMetadataId: string,
|
||||
fieldName: string,
|
||||
): Promise<string> {
|
||||
const { flatFieldMetadataMaps } =
|
||||
await this.flatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
|
||||
{
|
||||
workspaceId,
|
||||
flatMapsKeys: ['flatFieldMetadataMaps'],
|
||||
},
|
||||
);
|
||||
|
||||
const fieldMetadata = Object.values(
|
||||
flatFieldMetadataMaps.byUniversalIdentifier,
|
||||
).find(
|
||||
(field) =>
|
||||
field?.name === fieldName &&
|
||||
field?.objectMetadataId === objectMetadataId,
|
||||
);
|
||||
|
||||
if (!fieldMetadata) {
|
||||
throw new Error(
|
||||
`Field "${fieldName}" not found on this object. Use get_field_metadata to list available fields.`,
|
||||
);
|
||||
}
|
||||
|
||||
return fieldMetadata.id;
|
||||
}
|
||||
|
||||
generateReadTools(
|
||||
workspaceId: string,
|
||||
userWorkspaceId?: string,
|
||||
@@ -167,7 +214,7 @@ export class ViewToolsFactory {
|
||||
return {
|
||||
create_view: {
|
||||
description:
|
||||
'Create a new view for an object. Views define how records are displayed.',
|
||||
'Create a new view for an object. Views define how records are displayed. For KANBAN views, mainGroupByFieldName is required and must be a SELECT field (e.g., "stage", "status").',
|
||||
inputSchema: CreateViewInputSchema,
|
||||
execute: async (parameters: {
|
||||
name: string;
|
||||
@@ -175,6 +222,9 @@ export class ViewToolsFactory {
|
||||
icon?: string;
|
||||
type?: ViewType;
|
||||
visibility?: ViewVisibility;
|
||||
mainGroupByFieldName?: string;
|
||||
kanbanAggregateOperation?: string;
|
||||
kanbanAggregateOperationFieldName?: string;
|
||||
}) => {
|
||||
try {
|
||||
const objectMetadataId = await this.resolveObjectMetadataId(
|
||||
@@ -182,6 +232,26 @@ export class ViewToolsFactory {
|
||||
parameters.objectNameSingular,
|
||||
);
|
||||
|
||||
let mainGroupByFieldMetadataId: string | undefined;
|
||||
let kanbanAggregateOperationFieldMetadataId: string | undefined;
|
||||
|
||||
if (parameters.mainGroupByFieldName) {
|
||||
mainGroupByFieldMetadataId = await this.resolveFieldMetadataId(
|
||||
workspaceId,
|
||||
objectMetadataId,
|
||||
parameters.mainGroupByFieldName,
|
||||
);
|
||||
}
|
||||
|
||||
if (parameters.kanbanAggregateOperationFieldName) {
|
||||
kanbanAggregateOperationFieldMetadataId =
|
||||
await this.resolveFieldMetadataId(
|
||||
workspaceId,
|
||||
objectMetadataId,
|
||||
parameters.kanbanAggregateOperationFieldName,
|
||||
);
|
||||
}
|
||||
|
||||
const view = await this.viewService.createOne({
|
||||
createViewInput: {
|
||||
name: parameters.name,
|
||||
@@ -189,6 +259,10 @@ export class ViewToolsFactory {
|
||||
icon: parameters.icon ?? 'IconList',
|
||||
type: parameters.type ?? ViewType.TABLE,
|
||||
visibility: parameters.visibility ?? ViewVisibility.WORKSPACE,
|
||||
mainGroupByFieldMetadataId,
|
||||
kanbanAggregateOperation:
|
||||
parameters.kanbanAggregateOperation as AggregateOperations,
|
||||
kanbanAggregateOperationFieldMetadataId,
|
||||
},
|
||||
workspaceId,
|
||||
createdByUserWorkspaceId: userWorkspaceId,
|
||||
|
||||
Reference in New Issue
Block a user