feat(ai-tool): resolve and default icons in AI metadata tools (#23480)
## Context Objects and fields created through the AI chat / MCP metadata tools almost never get an icon, so they all render with the meaningless `123` fallback icon. Two causes: - The `icon` tool input was described only as `"Icon name"`, so the model had no idea what the value space is and mostly skipped an optional field it couldn't fill confidently. - Any invalid name is silently swapped for `Icon123` by `useIcons.getIcon` on the frontend, so near-misses were indistinguishable from unset. ## What this PR does **Guide the model** (icon names are Tabler names, which LLMs know well): - `icon` / `targetFieldIcon` schema descriptions now state the convention with examples (`IconBuildingSkyscraper`, `IconPaw`, …) and ask for one to always be set - The `metadata-building` skill gains an "Icons" section; the MCP server instructions gain a one-line reminder **Normalize server-side** (new `resolveIconName` util, used by all create/update/batch metadata tool executes incl. `relationCreationPayload.targetFieldIcon`): - Fixes shape mistakes: raw tabler slugs (`"building-skyscraper"`), separators, missing or lowercased `Icon` prefix - Deliberately does NOT validate existence against the full ~4.2k icon registry — an unknown name is harmless since the frontend falls back to its default icon, exactly as for icons stored via the API today - Unusable input (empty/garbage) resolves to nothing: creates fall back to a default, updates keep the existing icon **Fall back sensibly for fields**: - New `FIELD_TYPE_DEFAULT_ICONS` in `twenty-shared/constants` maps every `FieldMetadataType` to a sensible icon (mirroring the settings UI type illustrations), applied when the model provides no usable icon — an AI-created field always gets a meaningful icon - Lives in twenty-shared so the frontend can reuse it later (e.g. as `getIcon`'s custom default for fields) The REST/GraphQL metadata APIs are untouched — this only affects the AI tool layer. ## Test plan - `resolve-icon-name.util.spec.ts` — canonical pass-through, slug/prefix/separator fixes, unknown-name pass-through (FE fallback contract), unusable inputs, icon-key dropping on updates - `FieldTypeDefaultIcons.test.ts` — every field type mapped, all values canonically shaped (values hand-checked against the twenty-ui `ALL_ICONS` registry) - `nx typecheck` twenty-server + twenty-shared, oxlint/oxfmt clean on changed files <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/23480?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:
+92
-12
@@ -1,12 +1,14 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { type ToolSet } from 'ai';
|
||||
import { FIELD_TYPE_DEFAULT_ICONS } from 'twenty-shared/constants';
|
||||
import { FieldMetadataType, RelationType } from 'twenty-shared/types';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { METADATA_TOOL_EXCLUDED_FIELD_NAMES } from 'src/engine/core-modules/tool-provider/constants/metadata-tool-excluded-field-names.constant';
|
||||
import { compactMetadataOutput } from 'src/engine/core-modules/tool-provider/utils/compact-metadata-output.util';
|
||||
import { formatValidationErrors } from 'src/engine/core-modules/tool-provider/utils/format-validation-errors.util';
|
||||
import { normalizeIconName } from 'src/engine/core-modules/tool-provider/utils/normalize-icon-name.util';
|
||||
import { FieldMetadataService } from 'src/engine/metadata-modules/field-metadata/services/field-metadata.service';
|
||||
import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
|
||||
import { getObjectMetadataIdByName } from 'src/engine/metadata-modules/flat-object-metadata/utils/get-object-metadata-id-by-name.util';
|
||||
@@ -57,7 +59,12 @@ const CreateFieldMetadataInputSchema = z.object({
|
||||
name: z.string().describe('Field name (camelCase)'),
|
||||
label: z.string().describe('Display label'),
|
||||
description: z.string().optional().describe('Description'),
|
||||
icon: z.string().optional().describe('Icon name'),
|
||||
icon: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
'Tabler icon name, PascalCase with "Icon" prefix (e.g. IconCurrencyDollar, IconCalendarTime, IconPaw). Set one matching the field meaning; falls back to a type-based default.',
|
||||
),
|
||||
isNullable: z.boolean().optional().describe('Nullable'),
|
||||
isUnique: z.boolean().optional().describe('Unique constraint'),
|
||||
defaultValue: z.unknown().optional().describe('Default value'),
|
||||
@@ -79,7 +86,10 @@ const UpdateFieldMetadataInputSchema = z.object({
|
||||
name: z.string().optional().describe('Field name'),
|
||||
label: z.string().optional().describe('Display label'),
|
||||
description: z.string().optional().describe('Description'),
|
||||
icon: z.string().optional().describe('Icon name'),
|
||||
icon: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Tabler icon name (e.g. IconCurrencyDollar)'),
|
||||
isActive: z.boolean().optional().describe('Active state'),
|
||||
isNullable: z.boolean().optional().describe('Nullable'),
|
||||
isUnique: z.boolean().optional().describe('Unique constraint'),
|
||||
@@ -120,11 +130,18 @@ const CreateManyRelationFieldsInputSchema = z.object({
|
||||
name: z.string().describe('Field name (camelCase)'),
|
||||
label: z.string().describe('Display label'),
|
||||
description: z.string().optional().describe('Description'),
|
||||
icon: z.string().optional().describe('Icon name'),
|
||||
icon: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Tabler icon name for the relation field (e.g. IconUsers)'),
|
||||
type: z.nativeEnum(RelationType).describe('MANY_TO_ONE or ONE_TO_MANY'),
|
||||
targetObjectMetadataId: z.string().uuid().describe('Target object ID'),
|
||||
targetFieldLabel: z.string().describe('Inverse field label'),
|
||||
targetFieldIcon: z.string().describe('Inverse field icon'),
|
||||
targetFieldIcon: z
|
||||
.string()
|
||||
.describe(
|
||||
'Inverse field Tabler icon name (e.g. IconBuildingSkyscraper)',
|
||||
),
|
||||
}),
|
||||
)
|
||||
.min(1)
|
||||
@@ -132,6 +149,29 @@ const CreateManyRelationFieldsInputSchema = z.object({
|
||||
.describe('Relations to create (max 20).'),
|
||||
});
|
||||
|
||||
const normalizeRelationCreationPayloadIcon = (
|
||||
relationCreationPayload: unknown,
|
||||
): unknown => {
|
||||
if (
|
||||
!isDefined(relationCreationPayload) ||
|
||||
typeof relationCreationPayload !== 'object' ||
|
||||
Array.isArray(relationCreationPayload)
|
||||
) {
|
||||
return relationCreationPayload;
|
||||
}
|
||||
|
||||
const { targetFieldIcon } = relationCreationPayload as {
|
||||
targetFieldIcon?: string;
|
||||
};
|
||||
|
||||
return {
|
||||
...relationCreationPayload,
|
||||
targetFieldIcon:
|
||||
normalizeIconName(targetFieldIcon) ??
|
||||
FIELD_TYPE_DEFAULT_ICONS[FieldMetadataType.RELATION],
|
||||
};
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class FieldMetadataToolsFactory {
|
||||
constructor(
|
||||
@@ -249,9 +289,20 @@ export class FieldMetadataToolsFactory {
|
||||
relationCreationPayload?: unknown;
|
||||
}) => {
|
||||
try {
|
||||
const { icon, relationCreationPayload, ...createFieldInput } =
|
||||
parameters;
|
||||
|
||||
const flatFieldMetadata =
|
||||
await this.fieldMetadataService.createOneField({
|
||||
createFieldInput: parameters as Parameters<
|
||||
createFieldInput: {
|
||||
...createFieldInput,
|
||||
icon:
|
||||
normalizeIconName(icon) ??
|
||||
FIELD_TYPE_DEFAULT_ICONS[parameters.type],
|
||||
relationCreationPayload: normalizeRelationCreationPayloadIcon(
|
||||
relationCreationPayload,
|
||||
),
|
||||
} as Parameters<
|
||||
typeof this.fieldMetadataService.createOneField
|
||||
>[0]['createFieldInput'],
|
||||
workspaceId,
|
||||
@@ -291,11 +342,18 @@ export class FieldMetadataToolsFactory {
|
||||
isLabelSyncedWithName?: boolean;
|
||||
}) => {
|
||||
try {
|
||||
const { id, ...update } = parameters;
|
||||
const { id, icon, ...update } = parameters;
|
||||
const normalizedIcon = normalizeIconName(icon);
|
||||
|
||||
const flatFieldMetadata =
|
||||
await this.fieldMetadataService.updateOneField({
|
||||
updateFieldInput: { id, ...update } as Parameters<
|
||||
updateFieldInput: {
|
||||
id,
|
||||
...update,
|
||||
...(isDefined(normalizedIcon)
|
||||
? { icon: normalizedIcon }
|
||||
: {}),
|
||||
} as Parameters<
|
||||
typeof this.fieldMetadataService.updateOneField
|
||||
>[0]['updateFieldInput'],
|
||||
workspaceId,
|
||||
@@ -359,7 +417,17 @@ export class FieldMetadataToolsFactory {
|
||||
}) => {
|
||||
try {
|
||||
await this.fieldMetadataService.createManyFields({
|
||||
createFieldInputs: parameters.fields as Parameters<
|
||||
createFieldInputs: parameters.fields.map(
|
||||
({ icon, relationCreationPayload, ...createFieldInput }) => ({
|
||||
...createFieldInput,
|
||||
icon:
|
||||
normalizeIconName(icon) ??
|
||||
FIELD_TYPE_DEFAULT_ICONS[createFieldInput.type],
|
||||
relationCreationPayload: normalizeRelationCreationPayloadIcon(
|
||||
relationCreationPayload,
|
||||
),
|
||||
}),
|
||||
) as Parameters<
|
||||
typeof this.fieldMetadataService.createManyFields
|
||||
>[0]['createFieldInputs'],
|
||||
workspaceId,
|
||||
@@ -396,9 +464,17 @@ export class FieldMetadataToolsFactory {
|
||||
}) => {
|
||||
try {
|
||||
await Promise.all(
|
||||
parameters.fields.map(async ({ id, ...update }) => {
|
||||
parameters.fields.map(async ({ id, icon, ...update }) => {
|
||||
const normalizedIcon = normalizeIconName(icon);
|
||||
|
||||
await this.fieldMetadataService.updateOneField({
|
||||
updateFieldInput: { id, ...update } as Parameters<
|
||||
updateFieldInput: {
|
||||
id,
|
||||
...update,
|
||||
...(isDefined(normalizedIcon)
|
||||
? { icon: normalizedIcon }
|
||||
: {}),
|
||||
} as Parameters<
|
||||
typeof this.fieldMetadataService.updateOneField
|
||||
>[0]['updateFieldInput'],
|
||||
workspaceId,
|
||||
@@ -439,12 +515,16 @@ export class FieldMetadataToolsFactory {
|
||||
name: relation.name,
|
||||
label: relation.label,
|
||||
description: relation.description,
|
||||
icon: relation.icon,
|
||||
icon:
|
||||
normalizeIconName(relation.icon) ??
|
||||
FIELD_TYPE_DEFAULT_ICONS[FieldMetadataType.RELATION],
|
||||
relationCreationPayload: {
|
||||
type: relation.type,
|
||||
targetObjectMetadataId: relation.targetObjectMetadataId,
|
||||
targetFieldLabel: relation.targetFieldLabel,
|
||||
targetFieldIcon: relation.targetFieldIcon,
|
||||
targetFieldIcon:
|
||||
normalizeIconName(relation.targetFieldIcon) ??
|
||||
FIELD_TYPE_DEFAULT_ICONS[FieldMetadataType.RELATION],
|
||||
},
|
||||
})) as Parameters<
|
||||
typeof this.fieldMetadataService.createManyFields
|
||||
|
||||
+45
-9
@@ -8,6 +8,7 @@ import { z } from 'zod';
|
||||
import { METADATA_TOOL_EXCLUDED_FIELD_NAMES } from 'src/engine/core-modules/tool-provider/constants/metadata-tool-excluded-field-names.constant';
|
||||
import { compactMetadataOutput } from 'src/engine/core-modules/tool-provider/utils/compact-metadata-output.util';
|
||||
import { formatValidationErrors } from 'src/engine/core-modules/tool-provider/utils/format-validation-errors.util';
|
||||
import { normalizeIconName } from 'src/engine/core-modules/tool-provider/utils/normalize-icon-name.util';
|
||||
import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
|
||||
import { fromFlatObjectMetadataToObjectMetadataDto } from 'src/engine/metadata-modules/flat-object-metadata/utils/from-flat-object-metadata-to-object-metadata-dto.util';
|
||||
import { ObjectMetadataService } from 'src/engine/metadata-modules/object-metadata/object-metadata.service';
|
||||
@@ -65,7 +66,12 @@ const CreateObjectMetadataInputSchema = z.object({
|
||||
labelSingular: z.string().describe('Singular label (e.g. "Company")'),
|
||||
labelPlural: z.string().describe('Plural label (e.g. "Companies")'),
|
||||
description: z.string().optional().describe('Description'),
|
||||
icon: z.string().optional().describe('Icon name'),
|
||||
icon: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
'Tabler icon name, PascalCase with "Icon" prefix (e.g. IconBuildingSkyscraper, IconPaw, IconTargetArrow). Always set one matching what the object represents.',
|
||||
),
|
||||
shortcut: z.string().optional().describe('Keyboard shortcut'),
|
||||
isRemote: z.boolean().optional().describe('Remote object'),
|
||||
isLabelSyncedWithName: z
|
||||
@@ -81,7 +87,10 @@ const UpdateObjectMetadataInputSchema = z.object({
|
||||
nameSingular: z.string().optional().describe('Singular name'),
|
||||
namePlural: z.string().optional().describe('Plural name'),
|
||||
description: z.string().optional().describe('Description'),
|
||||
icon: z.string().optional().describe('Icon name'),
|
||||
icon: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Tabler icon name (e.g. IconBuildingSkyscraper)'),
|
||||
shortcut: z.string().optional().describe('Keyboard shortcut'),
|
||||
isActive: z.boolean().optional().describe('Active state'),
|
||||
labelIdentifierFieldMetadataId: z
|
||||
@@ -240,9 +249,14 @@ export class ObjectMetadataToolsFactory {
|
||||
isLabelSyncedWithName?: boolean;
|
||||
}) => {
|
||||
try {
|
||||
const { icon, ...createObjectInput } = parameters;
|
||||
|
||||
const flatObjectMetadata =
|
||||
await this.objectMetadataService.createOneObject({
|
||||
createObjectInput: parameters as Parameters<
|
||||
createObjectInput: {
|
||||
...createObjectInput,
|
||||
icon: normalizeIconName(icon),
|
||||
} as Parameters<
|
||||
typeof this.objectMetadataService.createOneObject
|
||||
>[0]['createObjectInput'],
|
||||
workspaceId,
|
||||
@@ -280,11 +294,20 @@ export class ObjectMetadataToolsFactory {
|
||||
isLabelSyncedWithName?: boolean;
|
||||
}) => {
|
||||
try {
|
||||
const { id, ...update } = parameters;
|
||||
const { id, icon, ...update } = parameters;
|
||||
const normalizedIcon = normalizeIconName(icon);
|
||||
|
||||
const flatObjectMetadata =
|
||||
await this.objectMetadataService.updateOneObject({
|
||||
updateObjectInput: { id, update },
|
||||
updateObjectInput: {
|
||||
id,
|
||||
update: {
|
||||
...update,
|
||||
...(isDefined(normalizedIcon)
|
||||
? { icon: normalizedIcon }
|
||||
: {}),
|
||||
},
|
||||
},
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
@@ -340,9 +363,12 @@ export class ObjectMetadataToolsFactory {
|
||||
}) => {
|
||||
try {
|
||||
await Promise.all(
|
||||
parameters.objects.map(async (createObjectInput) => {
|
||||
parameters.objects.map(async ({ icon, ...createObjectInput }) => {
|
||||
await this.objectMetadataService.createOneObject({
|
||||
createObjectInput: createObjectInput as Parameters<
|
||||
createObjectInput: {
|
||||
...createObjectInput,
|
||||
icon: normalizeIconName(icon),
|
||||
} as Parameters<
|
||||
typeof this.objectMetadataService.createOneObject
|
||||
>[0]['createObjectInput'],
|
||||
workspaceId,
|
||||
@@ -381,9 +407,19 @@ export class ObjectMetadataToolsFactory {
|
||||
}) => {
|
||||
try {
|
||||
await Promise.all(
|
||||
parameters.objects.map(async ({ id, ...update }) => {
|
||||
parameters.objects.map(async ({ id, icon, ...update }) => {
|
||||
const normalizedIcon = normalizeIconName(icon);
|
||||
|
||||
await this.objectMetadataService.updateOneObject({
|
||||
updateObjectInput: { id, update },
|
||||
updateObjectInput: {
|
||||
id,
|
||||
update: {
|
||||
...update,
|
||||
...(isDefined(normalizedIcon)
|
||||
? { icon: normalizedIcon }
|
||||
: {}),
|
||||
},
|
||||
},
|
||||
workspaceId,
|
||||
});
|
||||
}),
|
||||
|
||||
Reference in New Issue
Block a user