Fix AI demo workspace skill (#18575)

This PR fixes what allows to have a working demo workspace skill.

- Skill updated many times into something that works
- Fixed infinite loop in AI chat by memoizing ai-sdk output
- Finished navigateToView implementation
- Increased MAX_STEPS to 300 so the chat don't quit in the middle of a
long running skill
- Added CreateManyRelationFields
This commit is contained in:
Lucas Bordeau 2026-03-12 13:19:01 +01:00 committed by GitHub
parent db5b4d9c6c
commit cb3e32df86
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 262 additions and 54 deletions

View file

@ -24,6 +24,7 @@ import { mapDBMessagesToUIMessages } from '@/ai/utils/mapDBMessagesToUIMessages'
import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState';
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
import { useCallback, useMemo } from 'react';
import {
type GetChatThreadsQuery,
GetChatThreadsDocument,
@ -181,7 +182,11 @@ export const useAgentChatData = () => {
},
});
const isNewThread = currentAIChatThread === AGENT_CHAT_NEW_THREAD_DRAFT_KEY;
const isNewThread = useMemo(
() => currentAIChatThread === AGENT_CHAT_NEW_THREAD_DRAFT_KEY,
[currentAIChatThread],
);
const { loading: messagesLoading, data } = useGetChatMessagesQuery({
variables: { threadId: currentAIChatThread! },
skip: !isDefined(currentAIChatThread) || isNewThread,
@ -191,7 +196,7 @@ export const useAgentChatData = () => {
},
});
const ensureThreadForDraft = () => {
const ensureThreadForDraft = useCallback(() => {
const current = store.get(currentAIChatThreadState.atom);
if (current !== AGENT_CHAT_NEW_THREAD_DRAFT_KEY) {
return;
@ -218,9 +223,16 @@ export const useAgentChatData = () => {
threadIdPromise.finally(() => {
setPendingCreateFromDraftPromise(null);
});
};
}, [
createChatThread,
setPendingCreateFromDraftPromise,
store,
setIsCreatingChatThread,
]);
const ensureThreadIdForSend = async (): Promise<string | null> => {
const ensureThreadIdForSend = useCallback(async (): Promise<
string | null
> => {
const current = store.get(currentAIChatThreadState.atom);
if (current !== AGENT_CHAT_NEW_THREAD_DRAFT_KEY) {
return current;
@ -247,16 +259,33 @@ export const useAgentChatData = () => {
} finally {
setIsCreatingChatThread(false);
}
};
}, [createChatThread, store, setIsCreatingChatThread]);
const uiMessages = mapDBMessagesToUIMessages(data?.chatMessages || []);
const isLoading = messagesLoading || threadsLoading;
const threadsLoadingMemoized = useMemo(
() => threadsLoading,
[threadsLoading],
);
const messagesLoadingMemoized = useMemo(
() => messagesLoading,
[messagesLoading],
);
const uiMessages = useMemo(
() => mapDBMessagesToUIMessages(data?.chatMessages || []),
[data?.chatMessages],
);
const isLoading = useMemo(
() => messagesLoadingMemoized || threadsLoadingMemoized,
[messagesLoadingMemoized, threadsLoadingMemoized],
);
return {
uiMessages,
isLoading,
threadsLoading,
messagesLoading,
threadsLoading: threadsLoadingMemoized,
messagesLoading: messagesLoadingMemoized,
ensureThreadForDraft,
ensureThreadIdForSend,
};

View file

@ -2,6 +2,7 @@ import { AI_CHAT_SCROLL_WRAPPER_ID } from '@/ai/constants/AiChatScrollWrapperId'
import { useScrollWrapperHTMLElement } from '@/ui/utilities/scroll/hooks/useScrollWrapperHTMLElement';
import { scrollWrapperScrollBottomComponentState } from '@/ui/utilities/scroll/states/scrollWrapperScrollBottomComponentState';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { useCallback, useMemo } from 'react';
import { isDefined } from 'twenty-shared/utils';
const SCROLL_BOTTOM_THRESHOLD_PX = 10;
@ -16,9 +17,12 @@ export const useAgentChatScrollToBottom = () => {
AI_CHAT_SCROLL_WRAPPER_ID,
);
const isNearBottom = scrollWrapperScrollBottom <= SCROLL_BOTTOM_THRESHOLD_PX;
const isNearBottom = useMemo(
() => scrollWrapperScrollBottom <= SCROLL_BOTTOM_THRESHOLD_PX,
[scrollWrapperScrollBottom],
);
const scrollToBottom = () => {
const scrollToBottom = useCallback(() => {
const { scrollWrapperElement } = getScrollWrapperElement();
if (!isDefined(scrollWrapperElement)) {
return;
@ -27,7 +31,7 @@ export const useAgentChatScrollToBottom = () => {
scrollWrapperElement.scrollTo({
top: scrollWrapperElement.scrollHeight,
});
};
}, [getScrollWrapperElement]);
return { scrollToBottom, isNearBottom };
};

View file

@ -80,9 +80,26 @@ export const useProcessUIToolCallMessage = () => {
break;
}
case 'navigateToView':
// TODO: implement
case 'navigateToView': {
const viewObjectNamePlural = objectMetadataItems.find(
(item) =>
item.nameSingular === navigateAppOutput.objectNameSingular,
)?.namePlural;
if (!isDefined(viewObjectNamePlural)) {
throw new Error(
`Object with singular name ${navigateAppOutput.objectNameSingular} not found, cannot navigate to view from chat.`,
);
}
navigateApp(
AppPath.RecordIndexPage,
{ objectNamePlural: viewObjectNamePlural },
{ viewId: navigateAppOutput.viewId },
);
break;
}
case 'wait': {
await sleep(navigateAppOutput.durationMs);
break;

View file

@ -1,6 +1,6 @@
import { OBJECT_RECORD_OPERATION_BROWSER_EVENT_NAME } from '@/browser-event/constants/ObjectRecordOperationBrowserEventName';
import { type ObjectRecordOperation } from '@/object-record/types/ObjectRecordOperation';
import { type ObjectRecordOperationBrowserEventDetail } from '@/browser-event/types/ObjectRecordOperationBrowserEventDetail';
import { type ObjectRecordOperation } from '@/object-record/types/ObjectRecordOperation';
import { useEffect } from 'react';
import { isDefined, isNonEmptyArray } from 'twenty-shared/utils';

View file

@ -15,7 +15,7 @@ import { useAtomComponentStateCallbackState } from '@/ui/utilities/state/jotai/h
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { useStore } from 'jotai';
import { useMemo } from 'react';
import { useCallback, useMemo } from 'react';
import { computeRecordGqlOperationFilter } from 'twenty-shared/utils';
export const RecordTableEmptyHasNewRecordEffect = () => {
@ -79,20 +79,28 @@ export const RecordTableEmptyHasNewRecordEffect = () => {
operationSignature,
});
const handleObjectRecordOperation = (
objectRecordOperationEventDetail: ObjectRecordOperationBrowserEventDetail,
) => {
const objectRecordOperation = objectRecordOperationEventDetail.operation;
const handleObjectRecordOperation = useCallback(
(
objectRecordOperationEventDetail: ObjectRecordOperationBrowserEventDetail,
) => {
const objectRecordOperation = objectRecordOperationEventDetail.operation;
if (
objectRecordOperation.type.includes('update') ||
objectRecordOperation.type.includes('create')
) {
if (!isRecordTableInitialLoading && !recordTableHasRecords) {
store.set(recordTableWentFromEmptyToNotEmptyCallbackState, true);
if (
objectRecordOperation.type.includes('update') ||
objectRecordOperation.type.includes('create')
) {
if (!isRecordTableInitialLoading && !recordTableHasRecords) {
store.set(recordTableWentFromEmptyToNotEmptyCallbackState, true);
}
}
}
};
},
[
recordTableHasRecords,
isRecordTableInitialLoading,
store,
recordTableWentFromEmptyToNotEmptyCallbackState,
],
);
useListenToObjectRecordOperationBrowserEvent({
onObjectRecordOperationBrowserEvent: handleObjectRecordOperation,

View file

@ -1,13 +1,14 @@
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
import { dispatchObjectRecordOperationBrowserEvent } from '@/browser-event/utils/dispatchObjectRecordOperationBrowserEvent';
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
import { groupObjectRecordSseEventsByObjectMetadataItemNameSingular } from '@/sse-db-event/utils/groupObjectRecordSseEventsByObjectMetadataItemNameSingular';
import { turnSseObjectRecordEventsToObjectRecordOperationBrowserEvents } from '@/sse-db-event/utils/turnSseObjectRecordEventToObjectRecordOperationBrowserEvent';
import { useStore } from 'jotai';
import { useCallback } from 'react';
import { isDefined } from 'twenty-shared/utils';
import { type ObjectRecordEventWithQueryIds } from '~/generated-metadata/graphql';
export const useDispatchObjectRecordEventsFromSseToBrowserEvents = () => {
const { objectMetadataItems } = useObjectMetadataItems();
const store = useStore();
const dispatchObjectRecordEventsFromSseToBrowserEvents = useCallback(
(objectRecordEventsWithQueryIds: ObjectRecordEventWithQueryIds[]) => {
@ -24,6 +25,8 @@ export const useDispatchObjectRecordEventsFromSseToBrowserEvents = () => {
objectRecordEventsByObjectMetadataItemNameSingular.keys(),
);
const objectMetadataItems = store.get(objectMetadataItemsState.atom);
for (const objectMetadataItemNameSingular of objectMetadataItemNamesSingular) {
const objectRecordEventsForThisObjectMetadataItem =
objectRecordEventsByObjectMetadataItemNameSingular.get(
@ -49,7 +52,7 @@ export const useDispatchObjectRecordEventsFromSseToBrowserEvents = () => {
}
}
},
[objectMetadataItems],
[store],
);
return { dispatchObjectRecordEventsFromSseToBrowserEvents };

View file

@ -14,11 +14,11 @@ import { captureException } from '@sentry/react';
import { isNonEmptyString } from '@sniptt/guards';
import { print, type ExecutionResult } from 'graphql';
import { useStore } from 'jotai';
import { useCallback } from 'react';
import { isDefined } from 'twenty-shared/utils';
import { v4 } from 'uuid';
import { type EventSubscription } from '~/generated-metadata/graphql';
import { useStore } from 'jotai';
export const useTriggerEventStreamCreation = () => {
const store = useStore();
@ -79,7 +79,7 @@ export const useTriggerEventStreamCreation = () => {
onEventSubscription: EventSubscription;
}>,
) => {
if (isDefined(value?.errors)) {
if (isDefined(value?.errors) && Array.isArray(value.errors)) {
captureException(
new Error(`SSE subscription error: ${value.errors[0]?.message}`),
);

View file

@ -123,12 +123,41 @@ export class NavigateAppTool implements Tool {
};
}
const { flatObjectMetadataMaps } =
await this.workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
workspaceId,
flatMapsKeys: ['flatObjectMetadataMaps'],
},
);
const objectMetadataUniversalIdentifier =
flatObjectMetadataMaps.universalIdentifierById[
matchingView.objectMetadataId
];
const objectMetadata = isDefined(objectMetadataUniversalIdentifier)
? flatObjectMetadataMaps.byUniversalIdentifier[
objectMetadataUniversalIdentifier
]
: undefined;
if (!isDefined(objectMetadata)) {
return {
success: false,
message: `Object metadata for view "${matchingView.name}" not found`,
error: `Could not resolve the object associated with view "${matchingView.name}"`,
};
}
return {
success: true,
message: `Navigating to view "${matchingView.name}"`,
result: {
action: 'navigateToView',
viewId: matchingView.id,
viewName: matchingView.name,
objectNameSingular: objectMetadata.nameSingular,
},
};
}

View file

@ -1,4 +1,4 @@
export const AGENT_CONFIG = {
MAX_STEPS: 25,
MAX_STEPS: 300,
REASONING_BUDGET_TOKENS: 12000,
};

View file

@ -1,7 +1,7 @@
import { Injectable } from '@nestjs/common';
import { type ToolSet } from 'ai';
import { FieldMetadataType } from 'twenty-shared/types';
import { FieldMetadataType, RelationType } from 'twenty-shared/types';
import { z } from 'zod';
import { formatValidationErrors } from 'src/engine/core-modules/tool-provider/utils/format-validation-errors.util';
@ -120,6 +120,48 @@ const UpdateManyFieldMetadataInputSchema = z.object({
.describe('Array of field metadata updates to apply (1-20 items).'),
});
const CreateManyRelationFieldsInputSchema = z.object({
relations: z
.array(
z.object({
objectMetadataId: z
.string()
.uuid()
.describe('ID of the source object to add the relation field to'),
name: z
.string()
.describe('Internal name of the relation field (camelCase)'),
label: z.string().describe('Display label of the relation field'),
description: z
.string()
.optional()
.describe('Description of the relation field'),
icon: z
.string()
.optional()
.describe('Icon identifier for the relation field'),
type: z
.nativeEnum(RelationType)
.describe('Relation type: MANY_TO_ONE or ONE_TO_MANY'),
targetObjectMetadataId: z
.string()
.uuid()
.describe('ID of the target object this relation points to'),
targetFieldLabel: z
.string()
.describe(
'Display label for the inverse relation field on the target object',
),
targetFieldIcon: z
.string()
.describe('Icon for the inverse relation field (e.g. IconSomething)'),
}),
)
.min(1)
.max(20)
.describe('Array of relation fields to create (1-20 items).'),
});
@Injectable()
export class FieldMetadataToolsFactory {
constructor(private readonly fieldMetadataService: FieldMetadataService) {}
@ -316,6 +358,53 @@ export class FieldMetadataToolsFactory {
}),
);
return true;
} catch (error) {
if (error instanceof WorkspaceMigrationBuilderException) {
throw new Error(formatValidationErrors(error));
}
throw error;
}
},
},
create_many_relation_fields: {
description:
'Create multiple relation fields between objects at once. This is the recommended way to add relations after creating objects and non-relation fields. Each item specifies the source object, target object, relation type, and labels for both sides of the relation.',
inputSchema: CreateManyRelationFieldsInputSchema,
execute: async (parameters: {
relations: Array<{
objectMetadataId: string;
name: string;
label: string;
description?: string;
icon?: string;
type: RelationType;
targetObjectMetadataId: string;
targetFieldLabel: string;
targetFieldIcon: string;
}>;
}) => {
try {
await this.fieldMetadataService.createManyFields({
createFieldInputs: parameters.relations.map((relation) => ({
objectMetadataId: relation.objectMetadataId,
type: FieldMetadataType.RELATION,
name: relation.name,
label: relation.label,
description: relation.description,
icon: relation.icon,
relationCreationPayload: {
type: relation.type,
targetObjectMetadataId: relation.targetObjectMetadataId,
targetFieldLabel: relation.targetFieldLabel,
targetFieldIcon: relation.targetFieldIcon,
},
})) as Parameters<
typeof this.fieldMetadataService.createManyFields
>[0]['createFieldInputs'],
workspaceId,
});
return true;
} catch (error) {
if (error instanceof WorkspaceMigrationBuilderException) {

View file

@ -153,38 +153,65 @@ You will create a demo workspace that fits a particular type of company given by
Do not ask the user for more information, just be creative with the objects and fields, but stay professional and coherent.
The goal is to be able to tell a coherent and realistic story with the data, so for example if the user says it is a car repair shop,
we can create objects for cars, employees, repairs, customers, and the relevant relations between them,
and then we can seed data that tells the story of the car repair shop, for example we can create a customer, then create a car for that customer, then create a repair for that car, and so on.
We should end up with a dashboard that shows the relevant metrics for the car repair shop, for example the number of repairs, the revenue, the most common car brands, and so on.
Create relations fields between objects, for example a car repair shop workspace would have objects for cars, employees, repairs, customers, and the relevant relations between them.
DO NOT USE code-interpreter tool at all. Prefer more steps.
Create 5 to 7 objects, with 5 to 8 fields each, and the relevant relations between them.
LIMIT TO 3 OBJECTS FOR DEMO, AND 3 FIELDS FOR EACH OBJECT, to avoid bugs.
If you have to create multiple things you *MUST* use the relevant create many tool if it exists:
- Use *create_many_object_metadata* to create all objects at once
- Use *create_many_field_metadata* to create all non-relation fields at once
- Use *create_many_relation_fields* to create all relation fields between objects at once (do this AFTER creating the objects and non-relation fields)
If you have to wait use the navigate tool.
For the fields you will create, make sure to create a good variety of field types to showcase the different capabilities of the platform, for example:
- Create SELECT and SELECT_MULTIPLE field types for building demo board index views and table with groups views
- Create DATE_TIME fields to be able to create calendar views
- Create CURRENCY and NUMERIC fields for graphs
Here are the steps for you to work properly :
- Proceed object by object, for each object.
- Create the object with the right tool, DO THIS FIRST
- Wait 3 seconds before navigating, for the view to be populated by the backend
- Navigate to its default view
- Then create each relevant field metadata one by one, and create a view field for each of them, reorder them to the start so we see them.
- Then seed mock data relevant :
- use the tool that is related to the object, look for tools, create_my_new_object, create_many_of_my_new_object, look again in tools, don't use http
*Here are the steps to follow closely :*
STEP 1: Create all the objects at once with create_many_object_metadata, DO THIS FIRST
name must start with lowercase letter and contain only alphanumeric letters
STEP 2: Wait 3 seconds, for the backend side effects to be completed
STEP 3: Create all NON-RELATION fields for ALL objects by batch with create_many_field_metadata, do a batch for each object.
DO NOT include relation fields in this step. Only create TEXT, NUMBER, BOOLEAN, DATE_TIME, SELECT, MULTI_SELECT, CURRENCY, etc.
SELECT option values must be UPPER_SNAKE_CASE
STEP 4: Wait 3 seconds, for the backend side effects to be completed
STEP 5: Create all RELATION fields between objects at once with create_many_relation_fields
The name property should be camel-cased or the backend will throw, targetFieldLabel must be a string, targetFieldIcon must be a string, type must be one of the following values: MANY_TO_ONE, ONE_TO_MANY
targetFieldIcon is like IconSomething, it's ok if it doesn't exist in the icon library, it will just be a blank icon, but it needs to be a string that starts with Icon and is in PascalCase
STEP 6: Wait 3 seconds, for the backend side effects to be completed
STEP 7: For each object:
- Navigate the object's default view USE THE NAVIGATE TOOL
- Wait 3 seconds, so the user has time to see the object default view
- Create the view fields for the default view, use the create_many_view_fields tool, and make sure to include all created fields, including the relation fields, so that we have a complete view of the object with all its fields.
BE CAREFUL to use a position that will put those view fields right after the first label identifier field
which has a position of 0 and the next system created fields which begin at 1, *so use decimal positions between 0 and 1*
*YOU MUST CREATE ALL VIEW FIELDS FOR ALL FIELDS, INCLUDING RELATION FIELDS, IN THIS STEP, DO NOT LEAVE ANY FIELD WITHOUT A VIEW FIELD, OTHERWISE IT WILL NOT BE VISIBLE IN THE DEFAULT VIEW AND THE USER WON'T KNOW IT EXISTS*
- Then seed relevant and realistic mock data as we said earlier :
- use the relevant tool to create many records for this object
- between 20 and 50
- with a coherent combination of values
- proceed with the relevant tools for each object, do not use code-interpreter
- navigate to each default view before seeding an object, so the user can see what happens.
- use dates that are around TODAY so it's relevant for seeing past / future and present records
After you've finished with this part, let's proceed to the dashboard creation. We will create a dashboard with 4 graphs.
- Navigate to the dashboard list default view
- Create a new dashboard
Loop STEP 7 for all the objects
STEP 8 : After you've finished with this part, let's proceed to the dashboard creation. We will create a dashboard with 4 graphs.
- Create a new dashboard with a relevant story to showcase the data you've just created, for example if it's a car repair shop, you can create a dashboard that shows the number of repairs per month, the revenue per month, the most common car brands, and the distribution of repairs by employee.
- Navigate to the dashboard page
- Create 4 graphs :
- For each graph, find a relevant amount for y axis, a relevant date or select field for x axis, and if necessary a relevant group by stack
- Change the name of each graph so it is relevant
- Turn on the labels on the graphs
`,
isCustom: false,
},

View file

@ -5,7 +5,9 @@ export type NavigateAppToolOutput =
}
| {
action: 'navigateToView';
viewId: string;
viewName: string;
objectNameSingular: string;
}
| {
action: 'navigateToRecord';