[AI] Match ai chat composer to figma (#18874)

https://www.figma.com/design/xt8O9mFeLl46C5InWwoMrN/Twenty?node-id=93653-368288&t=obTG32NRidXid4lN-0

closes
https://discord.com/channels/1130383047699738754/1480990726442582086

---------

Co-authored-by: Félix Malfait <felix.malfait@gmail.com>
Co-authored-by: Félix Malfait <felix@twenty.com>
This commit is contained in:
nitin
2026-03-26 11:13:54 +05:30
committed by GitHub
parent dfe9cb4346
commit 578d990b9c
50 changed files with 641 additions and 406 deletions
@@ -10,7 +10,7 @@ import {
import { AgentResponseFormat } from 'src/engine/metadata-modules/ai/ai-agent/types/agent-response-format.type';
import { ModelConfiguration } from 'src/engine/metadata-modules/ai/ai-agent/types/modelConfiguration';
import { DEFAULT_SMART_MODEL } from 'src/engine/metadata-modules/ai/ai-models/types/default-smart-model.const';
import { AUTO_SELECT_SMART_MODEL_ID } from 'twenty-shared/constants';
import { type ModelId } from 'src/engine/metadata-modules/ai/ai-models/types/model-id.type';
import { SyncableEntity } from 'src/engine/workspace-manager/types/syncable-entity.interface';
import { JsonbProperty } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/jsonb-property.type';
@@ -43,7 +43,11 @@ export class AgentEntity
@Column({ nullable: false, type: 'text' })
prompt: string;
@Column({ nullable: false, type: 'varchar', default: DEFAULT_SMART_MODEL })
@Column({
nullable: false,
type: 'varchar',
default: AUTO_SELECT_SMART_MODEL_ID,
})
modelId: ModelId;
// Should not be nullable
@@ -39,9 +39,9 @@ import { AiModelRegistryService } from 'src/engine/metadata-modules/ai/ai-models
@Controller('rest/agent-chat')
@UseGuards(JwtAuthGuard, WorkspaceAuthGuard)
@UseFilters(
RestApiExceptionFilter,
AgentRestApiExceptionFilter,
BillingRestApiExceptionFilter,
RestApiExceptionFilter,
)
export class AgentChatController {
constructor(
@@ -59,6 +59,7 @@ export class AgentChatController {
threadId: string;
messages: ExtendedUIMessage[];
browsingContext?: BrowsingContextType | null;
modelId?: string;
},
@AuthUserWorkspaceId() userWorkspaceId: string,
@AuthWorkspace() workspace: WorkspaceEntity,
@@ -71,7 +72,7 @@ export class AgentChatController {
);
}
const resolvedModelId = workspace.smartModel;
const resolvedModelId = body.modelId ?? workspace.smartModel;
this.aiModelRegistryService.validateModelAvailability(
resolvedModelId,
@@ -96,6 +97,7 @@ export class AgentChatController {
threadId: body.threadId,
messages: body.messages,
browsingContext: body.browsingContext ?? null,
modelId: body.modelId,
userWorkspaceId,
workspace,
response,
@@ -33,6 +33,7 @@ export type StreamAgentChatOptions = {
response: Response;
messages: ExtendedUIMessage[];
browsingContext: BrowsingContextType | null;
modelId?: string;
};
@Injectable()
@@ -51,6 +52,7 @@ export class AgentChatStreamingService {
messages,
browsingContext,
response,
modelId,
}: StreamAgentChatOptions) {
const thread = await this.threadRepository.findOne({
where: {
@@ -114,6 +116,7 @@ export class AgentChatStreamingService {
messages,
browsingContext,
onCodeExecutionUpdate,
modelId,
});
let streamUsage = {
@@ -60,6 +60,7 @@ export type ChatExecutionOptions = {
messages: UIMessage<unknown, UIDataTypes, UITools>[];
browsingContext: BrowsingContextType | null;
onCodeExecutionUpdate?: CodeExecutionStreamEmitter;
modelId?: string;
};
export type ChatExecutionResult = {
@@ -89,6 +90,7 @@ export class ChatExecutionService {
messages,
browsingContext,
onCodeExecutionUpdate,
modelId,
}: ChatExecutionOptions): Promise<ChatExecutionResult> {
const { actorContext, roleId, userId, userContext } =
await this.agentActorContextService.buildUserAndAgentActorContext(
@@ -128,13 +130,16 @@ export class ChatExecutionService {
toolContext,
);
const modelId = workspace.smartModel;
const resolvedModelId = modelId ?? workspace.smartModel;
this.aiModelRegistryService.validateModelAvailability(modelId, workspace);
this.aiModelRegistryService.validateModelAvailability(
resolvedModelId,
workspace,
);
const registeredModel =
await this.aiModelRegistryService.resolveModelForAgent({
modelId,
modelId: resolvedModelId,
});
const modelConfig = this.aiModelRegistryService.getEffectiveModelConfig(
File diff suppressed because it is too large Load Diff
@@ -8,7 +8,7 @@ import { SdkProviderFactoryService } from 'src/engine/metadata-modules/ai/ai-mod
import { buildCompositeModelId } from 'src/engine/metadata-modules/ai/ai-models/utils/composite-model-id.util';
import { loadDefaultAiProviders } from 'src/engine/metadata-modules/ai/ai-models/utils/load-default-ai-providers.util';
import { type AiProvidersConfig } from 'src/engine/metadata-modules/ai/ai-models/types/ai-providers-config.type';
import { DEFAULT_SMART_MODEL } from 'src/engine/metadata-modules/ai/ai-models/types/default-smart-model.const';
import { AUTO_SELECT_SMART_MODEL_ID } from 'twenty-shared/constants';
const DEFAULT_PROVIDERS: AiProvidersConfig = loadDefaultAiProviders();
@@ -125,13 +125,15 @@ describe('AiModelRegistryService', () => {
service = module.get<AiModelRegistryService>(AiModelRegistryService);
});
it('should throw when no models are available for DEFAULT_SMART_MODEL', () => {
expect(() => service.getEffectiveModelConfig(DEFAULT_SMART_MODEL)).toThrow(
it('should throw when no models are available for AUTO_SELECT_SMART_MODEL_ID', () => {
expect(() =>
service.getEffectiveModelConfig(AUTO_SELECT_SMART_MODEL_ID),
).toThrow(
'No AI models are available. Configure at least one AI provider.',
);
});
it('should return effective model config for DEFAULT_SMART_MODEL when models are available', () => {
it('should return effective model config for AUTO_SELECT_SMART_MODEL_ID when models are available', () => {
jest.spyOn(service, 'getAvailableModels').mockReturnValue([
{
modelId: 'openai/gpt-5.2',
@@ -146,14 +148,14 @@ describe('AiModelRegistryService', () => {
model: {} as any,
});
const result = service.getEffectiveModelConfig(DEFAULT_SMART_MODEL);
const result = service.getEffectiveModelConfig(AUTO_SELECT_SMART_MODEL_ID);
expect(result).toBeDefined();
expect(result.modelId).toBe('openai/gpt-5.2');
expect(result.sdkPackage).toBe('@ai-sdk/openai');
});
it('should return effective model config for DEFAULT_SMART_MODEL with custom model', () => {
it('should return effective model config for AUTO_SELECT_SMART_MODEL_ID with custom model', () => {
jest.spyOn(service, 'getAvailableModels').mockReturnValue([
{
modelId: 'custom/mistral',
@@ -168,7 +170,7 @@ describe('AiModelRegistryService', () => {
model: {} as any,
});
const result = service.getEffectiveModelConfig(DEFAULT_SMART_MODEL);
const result = service.getEffectiveModelConfig(AUTO_SELECT_SMART_MODEL_ID);
expect(result).toBeDefined();
expect(result.modelId).toBe('custom/mistral');
@@ -17,12 +17,15 @@ import { type AiProviderConfig } from 'src/engine/metadata-modules/ai/ai-models/
import { type AiProviderModelConfig } from 'src/engine/metadata-modules/ai/ai-models/types/ai-provider-model-config.type';
import { type AiProvidersConfig } from 'src/engine/metadata-modules/ai/ai-models/types/ai-providers-config.type';
import { DEFAULT_CONTEXT_WINDOW_TOKENS } from 'src/engine/metadata-modules/ai/ai-models/types/default-context-window-tokens.const';
import { DEFAULT_FAST_MODEL } from 'src/engine/metadata-modules/ai/ai-models/types/default-fast-model.const';
import {
AUTO_SELECT_FAST_MODEL_ID,
AUTO_SELECT_SMART_MODEL_ID,
} from 'twenty-shared/constants';
import { isAutoSelectModelId } from 'twenty-shared/utils';
import { DEFAULT_MAX_OUTPUT_TOKENS } from 'src/engine/metadata-modules/ai/ai-models/types/default-max-output-tokens.const';
import { DEFAULT_SMART_MODEL } from 'src/engine/metadata-modules/ai/ai-models/types/default-smart-model.const';
import { buildCompositeModelId } from 'src/engine/metadata-modules/ai/ai-models/utils/composite-model-id.util';
import { inferModelFamily } from 'src/engine/metadata-modules/ai/ai-models/utils/infer-model-family.util';
import { isDefaultModelSentinel } from 'src/engine/metadata-modules/ai/ai-models/utils/is-default-model-sentinel.util';
import {
isModelAllowedByWorkspace,
type WorkspaceModelAvailabilitySettings,
@@ -203,9 +206,9 @@ export class AiModelRegistryService {
}
getEffectiveModelConfig(modelId: string): AIModelConfig {
if (isDefaultModelSentinel(modelId)) {
if (isAutoSelectModelId(modelId)) {
const defaultModel =
modelId === DEFAULT_FAST_MODEL
modelId === AUTO_SELECT_FAST_MODEL_ID
? this.getDefaultSpeedModel()
: this.getDefaultPerformanceModel();
@@ -253,7 +256,7 @@ export class AiModelRegistryService {
}
isModelAdminAllowed(modelId: string): boolean {
if (isDefaultModelSentinel(modelId)) {
if (isAutoSelectModelId(modelId)) {
return true;
}
@@ -360,7 +363,7 @@ export class AiModelRegistryService {
resolveModelForAgent(agent: { modelId: string } | null): RegisteredAIModel {
const aiModel = this.getEffectiveModelConfig(
agent?.modelId ?? DEFAULT_SMART_MODEL,
agent?.modelId ?? AUTO_SELECT_SMART_MODEL_ID,
);
const registeredModel = this.getModel(aiModel.modelId);
@@ -1 +0,0 @@
export const DEFAULT_FAST_MODEL = 'default-fast-model' as const;
@@ -1 +0,0 @@
export const DEFAULT_SMART_MODEL = 'default-smart-model' as const;
@@ -1,7 +1,7 @@
export enum ModelFamily {
GPT = 'gpt',
CLAUDE = 'claude',
GEMINI = 'gemini',
MISTRAL = 'mistral',
GROK = 'grok',
GPT = 'GPT',
CLAUDE = 'CLAUDE',
GEMINI = 'GEMINI',
MISTRAL = 'MISTRAL',
GROK = 'GROK',
}
@@ -1,5 +0,0 @@
import { DEFAULT_FAST_MODEL } from 'src/engine/metadata-modules/ai/ai-models/types/default-fast-model.const';
import { DEFAULT_SMART_MODEL } from 'src/engine/metadata-modules/ai/ai-models/types/default-smart-model.const';
export const isDefaultModelSentinel = (modelId: string): boolean =>
modelId === DEFAULT_FAST_MODEL || modelId === DEFAULT_SMART_MODEL;
@@ -1,4 +1,4 @@
import { isDefaultModelSentinel } from 'src/engine/metadata-modules/ai/ai-models/utils/is-default-model-sentinel.util';
import { isAutoSelectModelId } from 'twenty-shared/utils';
export type WorkspaceModelAvailabilitySettings = {
useRecommendedModels: boolean;
@@ -10,7 +10,7 @@ export const isModelAllowedByWorkspace = (
workspace: WorkspaceModelAvailabilitySettings,
recommendedModelIds?: Set<string>,
): boolean => {
if (isDefaultModelSentinel(modelId)) {
if (isAutoSelectModelId(modelId)) {
return true;
}