[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
@@ -1,67 +1,28 @@
import { AIChatBanner } from '@/ai/components/AIChatBanner';
import { useEndSubscriptionTrialPeriod } from '@/settings/billing/hooks/useEndSubscriptionTrialPeriod';
import { useRedirect } from '@/domain-manager/hooks/useRedirect';
import { usePermissionFlagMap } from '@/settings/roles/hooks/usePermissionFlagMap';
import { useSubscriptionStatus } from '@/workspace/hooks/useSubscriptionStatus';
import { t } from '@lingui/core/macro';
import { useState } from 'react';
import { SettingsPath } from 'twenty-shared/types';
import { getSettingsPath, isDefined } from 'twenty-shared/utils';
import { IconSparkles } from 'twenty-ui/display';
import { useQuery } from '@apollo/client/react';
import {
PermissionFlagType,
SubscriptionStatus,
BillingPortalSessionDocument,
} from '~/generated-metadata/graphql';
import { useNavigateSettings } from '~/hooks/useNavigateSettings';
export const AIChatCreditsExhaustedMessage = () => {
const { redirect } = useRedirect();
const navigateSettings = useNavigateSettings();
const subscriptionStatus = useSubscriptionStatus();
const { endTrialPeriod, isLoading: isEndingTrial } =
useEndSubscriptionTrialPeriod();
const [isProcessing, setIsProcessing] = useState(false);
const isTrialing = subscriptionStatus === SubscriptionStatus.Trialing;
const { [PermissionFlagType.WORKSPACE]: hasPermissionToManageBilling } =
usePermissionFlagMap();
const { data: billingPortalData, loading: isBillingPortalLoading } = useQuery(
BillingPortalSessionDocument,
{
variables: {
returnUrlPath: getSettingsPath(SettingsPath.Billing),
},
},
);
const openBillingPortal = () => {
if (
isDefined(billingPortalData) &&
isDefined(billingPortalData.billingPortalSession.url)
) {
redirect(billingPortalData.billingPortalSession.url);
}
const handleUpgradeClick = () => {
navigateSettings(SettingsPath.Billing);
};
const handleUpgradeClick = async () => {
if (!isTrialing) {
openBillingPortal();
return;
}
setIsProcessing(true);
const result = await endTrialPeriod();
setIsProcessing(false);
if (!result.success) {
openBillingPortal();
}
};
const isLoading = isEndingTrial || isBillingPortalLoading || isProcessing;
const message = hasPermissionToManageBilling
? isTrialing
? t`Free trial credits exhausted. Subscribe now to continue using AI features.`
@@ -79,8 +40,6 @@ export const AIChatCreditsExhaustedMessage = () => {
buttonOnClick={
hasPermissionToManageBilling ? handleUpgradeClick : undefined
}
isButtonDisabled={isLoading}
isButtonLoading={isLoading}
/>
);
};
@@ -1,6 +1,5 @@
import { styled } from '@linaria/react';
import { EditorContent } from '@tiptap/react';
import { LightButton } from 'twenty-ui/input';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { AIChatEmptyState } from '@/ai/components/AIChatEmptyState';
@@ -12,10 +11,17 @@ import { AIChatEditorFocusEffect } from '@/ai/components/internal/AIChatEditorFo
import { AIChatSkeletonLoader } from '@/ai/components/internal/AIChatSkeletonLoader';
import { SendMessageButton } from '@/ai/components/internal/SendMessageButton';
import { useAIChatEditor } from '@/ai/hooks/useAIChatEditor';
import { useAiModelLabel } from '@/ai/hooks/useAiModelOptions';
import { useAgentChatModelId } from '@/ai/hooks/useAgentChatModelId';
import { useWorkspaceAiModelAvailability } from '@/ai/hooks/useWorkspaceAiModelAvailability';
import { agentChatUserSelectedModelState } from '@/ai/states/agentChatUserSelectedModelState';
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { aiModelsState } from '@/client-config/states/aiModelsState';
import { getModelIcon } from '@/settings/admin-panel/ai/utils/getModelIcon';
import { Select } from '@/ui/input/components/Select';
import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile';
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { t } from '@lingui/core/macro';
const StyledInputArea = styled.div<{ isMobile: boolean }>`
align-items: flex-end;
@@ -102,24 +108,48 @@ const StyledRightButtonsContainer = styled.div`
gap: ${themeCssVariables.spacing[1]};
`;
const StyledReadOnlyModelButtonContainer = styled.div`
> * {
cursor: default;
&:hover,
&:active {
background: transparent;
}
}
`;
export const AIChatEditorSection = () => {
const isMobile = useIsMobile();
const currentWorkspace = useAtomStateValue(currentWorkspaceState);
const smartModelLabel = useAiModelLabel(currentWorkspace?.smartModel, false);
const aiModels = useAtomStateValue(aiModelsState);
const { enabledModels } = useWorkspaceAiModelAvailability();
const setAgentChatUserSelectedModel = useSetAtomState(
agentChatUserSelectedModelState,
);
const { selectedModelId } = useAgentChatModelId();
const { editor, handleSendAndClear } = useAIChatEditor();
const workspaceSmartModel = aiModels.find(
(model) => model.modelId === currentWorkspace?.smartModel,
);
const resolvedDefaultModelId = enabledModels.find(
(model) =>
model.label === workspaceSmartModel?.label &&
model.providerName === workspaceSmartModel?.providerName,
)?.modelId;
const defaultPinnedOption = workspaceSmartModel
? {
value: null as string | null,
label: workspaceSmartModel.label,
Icon: getModelIcon(
workspaceSmartModel.modelFamily,
workspaceSmartModel.providerName,
),
contextualText: t`default`,
}
: undefined;
const smartModelOptions = enabledModels
.filter((model) => model.modelId !== resolvedDefaultModelId)
.map((model) => ({
value: model.modelId as string | null,
label: model.label,
Icon: getModelIcon(model.modelFamily, model.providerName),
}));
return (
<>
<AIChatEditorFocusEffect editor={editor} />
@@ -139,9 +169,17 @@ export const AIChatEditorSection = () => {
<AIChatContextUsageButton />
</StyledLeftButtonsContainer>
<StyledRightButtonsContainer>
<StyledReadOnlyModelButtonContainer>
<LightButton accent="tertiary" title={smartModelLabel} />
</StyledReadOnlyModelButtonContainer>
<Select
dropdownId="ai-chat-smart-model-select"
value={selectedModelId}
onChange={setAgentChatUserSelectedModel}
options={smartModelOptions}
pinnedOption={defaultPinnedOption}
selectSizeVariant="small"
showContextualTextInControl={false}
withSearchInput
dropdownOffset={{ x: 0, y: 8 }}
/>
<SendMessageButton onSend={handleSendAndClear} />
</StyledRightButtonsContainer>
</StyledButtonsContainer>
@@ -1,4 +1,4 @@
import { AIChatStandaloneError } from '@/ai/components/AIChatStandaloneError';
import { AIChatErrorRenderer } from '@/ai/components/AIChatErrorRenderer';
import { AgentMessageRole } from '@/ai/constants/AgentMessageRole';
import { agentChatErrorState } from '@/ai/states/agentChatErrorState';
import { agentChatIsStreamingState } from '@/ai/states/agentChatIsStreamingState';
@@ -7,6 +7,12 @@ import { agentChatMessageIdsComponentSelector } from '@/ai/states/agentChatMessa
import { useAtomComponentFamilySelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentFamilySelectorValue';
import { useAtomComponentSelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentSelectorValue';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { styled } from '@linaria/react';
import { themeCssVariables } from 'twenty-ui/theme-constants';
const StyledErrorWrapper = styled.div`
padding-top: ${themeCssVariables.spacing[3]};
`;
export const AIChatErrorUnderMessageList = () => {
const agentChatError = useAtomStateValue(agentChatErrorState);
@@ -31,5 +37,9 @@ export const AIChatErrorUnderMessageList = () => {
return null;
}
return <AIChatStandaloneError />;
return (
<StyledErrorWrapper>
<AIChatErrorRenderer error={agentChatError} />
</StyledErrorWrapper>
);
};
@@ -6,6 +6,7 @@ import { useEnsureAgentChatThreadIdForSend } from '@/ai/hooks/useEnsureAgentChat
import { agentChatErrorState } from '@/ai/states/agentChatErrorState';
import { agentChatIsLoadingState } from '@/ai/states/agentChatIsLoadingState';
import { agentChatIsStreamingState } from '@/ai/states/agentChatIsStreamingState';
import { normalizeAiSdkError } from '@/ai/utils/normalizeAiSdkError';
import { agentChatMessagesComponentFamilyState } from '@/ai/states/agentChatMessagesComponentFamilyState';
import { agentChatMessagesLoadingState } from '@/ai/states/agentChatMessagesLoadingState';
import { agentChatThreadsLoadingState } from '@/ai/states/agentChatThreadsLoadingState';
@@ -119,7 +120,7 @@ export const AgentChatAiSdkStreamEffect = () => {
const setAgentChatError = useSetAtomState(agentChatErrorState);
useEffect(() => {
setAgentChatError(chatState.error);
setAgentChatError(normalizeAiSdkError(chatState.error));
}, [chatState.error, setAgentChatError]);
const setAgentChatIsStreaming = useSetAtomState(agentChatIsStreamingState);
@@ -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;
@@ -15,6 +15,7 @@ import {
agentChatDraftsByThreadIdState,
} from '@/ai/states/agentChatDraftsByThreadIdState';
import { agentChatInputState } from '@/ai/states/agentChatInputState';
import { useAgentChatModelId } from '@/ai/hooks/useAgentChatModelId';
import { REST_API_BASE_URL } from '@/apollo/constant/rest-api-base-url';
import { getTokenPair } from '@/apollo/utils/getTokenPair';
import { renewToken } from '@/auth/services/AuthService';
@@ -40,6 +41,7 @@ export const useAgentChat = (
const setTokenPair = useSetAtomState(tokenPairState);
const setAgentChatUsage = useSetAtomState(agentChatUsageState);
const { modelIdForRequest } = useAgentChatModelId();
const { getBrowsingContext } = useGetBrowsingContext();
const setCurrentAIChatThreadTitle = useSetAtomState(
currentAIChatThreadTitleState,
@@ -258,6 +260,9 @@ export const useAgentChat = (
body: {
threadId,
browsingContext,
...(isDefined(modelIdForRequest) && {
modelId: modelIdForRequest,
}),
},
},
);
@@ -273,6 +278,7 @@ export const useAgentChat = (
agentChatUploadedFiles,
setAgentChatUploadedFiles,
setAgentChatDraftsByThreadId,
modelIdForRequest,
]);
useListenToBrowserEvent({
@@ -0,0 +1,23 @@
import { isDefined } from 'twenty-shared/utils';
import { useWorkspaceAiModelAvailability } from '@/ai/hooks/useWorkspaceAiModelAvailability';
import { agentChatUserSelectedModelState } from '@/ai/states/agentChatUserSelectedModelState';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
export const useAgentChatModelId = () => {
const { enabledModels } = useWorkspaceAiModelAvailability();
const agentChatUserSelectedModel = useAtomStateValue(
agentChatUserSelectedModelState,
);
const isUserModelAvailable =
!isDefined(agentChatUserSelectedModel) ||
enabledModels.some((model) => model.modelId === agentChatUserSelectedModel);
const selectedModelId = isUserModelAvailable
? agentChatUserSelectedModel
: null;
const modelIdForRequest = selectedModelId ?? undefined;
return { selectedModelId, modelIdForRequest };
};
@@ -1,7 +1,6 @@
import { type SelectOption } from 'twenty-ui/input';
import { isAutoSelectModelId } from 'twenty-shared/utils';
import { DEFAULT_FAST_MODEL } from '@/ai/constants/DefaultFastModel';
import { DEFAULT_SMART_MODEL } from '@/ai/constants/DefaultSmartModel';
import { useWorkspaceAiModelAvailability } from '@/ai/hooks/useWorkspaceAiModelAvailability';
import { aiModelsState } from '@/client-config/states/aiModelsState';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
@@ -16,13 +15,11 @@ export const useAiModelOptions = (): SelectOption<string>[] => {
)
.map((model) => ({
value: model.modelId,
label:
model.modelId === DEFAULT_FAST_MODEL ||
model.modelId === DEFAULT_SMART_MODEL
? model.label
: model.modelFamilyLabel
? `${model.label} (${model.modelFamilyLabel})`
: model.label,
label: isAutoSelectModelId(model.modelId)
? model.label
: model.modelFamilyLabel
? `${model.label} (${model.modelFamilyLabel})`
: model.label,
}))
.sort((a, b) => a.label.localeCompare(b.label));
};
@@ -43,11 +40,7 @@ export const useAiModelLabel = (
return modelId;
}
if (
model.modelId === DEFAULT_FAST_MODEL ||
model.modelId === DEFAULT_SMART_MODEL ||
!includeProvider
) {
if (isAutoSelectModelId(model.modelId) || !includeProvider) {
return model.label;
}
@@ -1,17 +1,10 @@
import { DEFAULT_FAST_MODEL } from '@/ai/constants/DefaultFastModel';
import { DEFAULT_SMART_MODEL } from '@/ai/constants/DefaultSmartModel';
import { isAutoSelectModelId } from 'twenty-shared/utils';
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { aiModelsState } from '@/client-config/states/aiModelsState';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { type ClientAiModelConfig } from '~/generated-metadata/graphql';
const VIRTUAL_MODEL_IDS: Set<string> = new Set([
DEFAULT_SMART_MODEL,
DEFAULT_FAST_MODEL,
]);
const isVirtualModel = (modelId: string) => VIRTUAL_MODEL_IDS.has(modelId);
export const useWorkspaceAiModelAvailability = () => {
const aiModels = useAtomStateValue(aiModelsState);
const currentWorkspace = useAtomStateValue(currentWorkspaceState);
@@ -23,7 +16,7 @@ export const useWorkspaceAiModelAvailability = () => {
modelId: string,
model?: ClientAiModelConfig,
): boolean => {
if (isVirtualModel(modelId)) {
if (isAutoSelectModelId(modelId)) {
return true;
}
@@ -35,7 +28,7 @@ export const useWorkspaceAiModelAvailability = () => {
};
const realModels = aiModels.filter(
(model) => !isVirtualModel(model.modelId) && !model.isDeprecated,
(model) => !isAutoSelectModelId(model.modelId) && !model.isDeprecated,
);
const enabledModels = realModels.filter((model) =>
@@ -0,0 +1,8 @@
import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState';
export const agentChatUserSelectedModelState = createAtomState<string | null>({
key: 'ai/agentChatUserSelectedModel',
defaultValue: null,
useLocalStorage: true,
localStorageOptions: { getOnInit: true },
});
@@ -0,0 +1,60 @@
import { normalizeAiSdkError } from '@/ai/utils/normalizeAiSdkError';
describe('normalizeAiSdkError', () => {
it('should return undefined for undefined input', () => {
expect(normalizeAiSdkError(undefined)).toBeUndefined();
});
it('should return the error unchanged if it already has a code', () => {
const error = new Error('test') as Error & { code: string };
error.code = 'EXISTING_CODE';
const result = normalizeAiSdkError(error);
expect(result).toBe(error);
expect((result as Error & { code: string }).code).toBe('EXISTING_CODE');
});
it('should parse JSON message and attach code from response body', () => {
const error = new Error(
'{"statusCode":402,"error":"Error","messages":["Credits exhausted"],"code":"BILLING_CREDITS_EXHAUSTED"}',
);
const result = normalizeAiSdkError(error);
expect(result).not.toBe(error);
expect((result as Error & { code: string }).code).toBe(
'BILLING_CREDITS_EXHAUSTED',
);
expect(result?.message).toBe(error.message);
});
it('should return original error for non-JSON message', () => {
const error = new Error('Something went wrong');
const result = normalizeAiSdkError(error);
expect(result).toBe(error);
});
it('should return original error for JSON message without code', () => {
const error = new Error(
'{"statusCode":500,"error":"Internal Server Error"}',
);
const result = normalizeAiSdkError(error);
expect(result).toBe(error);
});
it('should preserve the original stack trace', () => {
const error = new Error(
'{"statusCode":402,"code":"BILLING_CREDITS_EXHAUSTED"}',
);
const originalStack = error.stack;
const result = normalizeAiSdkError(error);
expect(result?.stack).toBe(originalStack);
});
});
@@ -0,0 +1,43 @@
import { isDefined } from 'twenty-shared/utils';
// The Vercel AI SDK wraps non-200 responses into an Error whose message
// is the raw JSON response body, losing any custom properties (like `code`).
// This function parses that JSON and re-attaches the code so downstream
// consumers (extractErrorCode) can find it without knowing about the SDK.
export const normalizeAiSdkError = (
error: Error | undefined,
): Error | undefined => {
if (!isDefined(error) || !(error instanceof Error)) {
return error;
}
if (
'code' in error &&
typeof (error as Error & { code: string }).code === 'string'
) {
return error;
}
try {
const parsed: unknown = JSON.parse(error.message);
if (
isDefined(parsed) &&
typeof parsed === 'object' &&
'code' in parsed &&
typeof (parsed as { code: unknown }).code === 'string'
) {
const normalizedError = new Error(error.message) as Error & {
code: string;
};
normalizedError.code = (parsed as { code: string }).code;
normalizedError.stack = error.stack;
return normalizedError;
}
} catch {
// message is not JSON — nothing to normalize
}
return error;
};
@@ -2,8 +2,10 @@ import { gql, InMemoryCache } from '@apollo/client';
import { CombinedGraphQLErrors } from '@apollo/client/errors';
import fetchMock, { enableFetchMocks } from 'jest-fetch-mock';
import { DEFAULT_FAST_MODEL } from '@/ai/constants/DefaultFastModel';
import { DEFAULT_SMART_MODEL } from '@/ai/constants/DefaultSmartModel';
import {
AUTO_SELECT_FAST_MODEL_ID,
AUTO_SELECT_SMART_MODEL_ID,
} from 'twenty-shared/constants';
import { ApolloFactory, type Options } from '@/apollo/services/apollo.factory';
import { CUSTOM_WORKSPACE_APPLICATION_MOCK } from '@/object-metadata/hooks/__tests__/constants/CustomWorkspaceApplicationMock.test.constant';
import { WorkspaceActivationStatus } from '~/generated-metadata/graphql';
@@ -71,8 +73,8 @@ const mockWorkspace = {
isTwoFactorAuthenticationEnforced: false,
trashRetentionDays: 14,
eventLogRetentionDays: 365 * 3,
fastModel: DEFAULT_FAST_MODEL,
smartModel: DEFAULT_SMART_MODEL,
fastModel: AUTO_SELECT_FAST_MODEL_ID,
smartModel: AUTO_SELECT_SMART_MODEL_ID,
routerModel: 'auto',
enabledAiModelIds: [],
useRecommendedModels: true,
@@ -1,7 +1,9 @@
import { renderHook } from '@testing-library/react';
import { DEFAULT_FAST_MODEL } from '@/ai/constants/DefaultFastModel';
import { DEFAULT_SMART_MODEL } from '@/ai/constants/DefaultSmartModel';
import {
AUTO_SELECT_FAST_MODEL_ID,
AUTO_SELECT_SMART_MODEL_ID,
} from 'twenty-shared/constants';
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { CUSTOM_WORKSPACE_APPLICATION_MOCK } from '@/object-metadata/hooks/__tests__/constants/CustomWorkspaceApplicationMock.test.constant';
import { useColumnDefinitionsFromObjectMetadata } from '@/object-metadata/hooks/useColumnDefinitionsFromObjectMetadata';
@@ -68,8 +70,8 @@ describe('useColumnDefinitionsFromObjectMetadata', () => {
isTwoFactorAuthenticationEnforced: false,
trashRetentionDays: 14,
eventLogRetentionDays: 365 * 3,
fastModel: DEFAULT_FAST_MODEL,
smartModel: DEFAULT_SMART_MODEL,
fastModel: AUTO_SELECT_FAST_MODEL_ID,
smartModel: AUTO_SELECT_SMART_MODEL_ID,
enabledAiModelIds: [],
useRecommendedModels: true,
});
@@ -34,38 +34,38 @@ export const DateTimeSettingsDateFormatSelect = ({
return (
<Select
dropdownId="datetime-settings-date-format"
dropdownWidth={218}
dropdownWidth={320}
label={t`Date format`}
fullWidth
dropdownWidthAuto
value={value}
pinnedOption={{
label: t`System settings`,
value: DateFormat.SYSTEM,
contextualText: systemDateFormatLabel,
}}
options={[
{
label: t`System settings - ${systemDateFormatLabel}`,
value: DateFormat.SYSTEM,
},
{
label: `${formatInTimeZone(
label: formatInTimeZone(
Date.now(),
usedTimeZone,
DateFormat.MONTH_FIRST,
)}`,
),
value: DateFormat.MONTH_FIRST,
},
{
label: `${formatInTimeZone(
label: formatInTimeZone(
Date.now(),
usedTimeZone,
DateFormat.DAY_FIRST,
)}`,
),
value: DateFormat.DAY_FIRST,
},
{
label: `${formatInTimeZone(
label: formatInTimeZone(
Date.now(),
usedTimeZone,
DateFormat.YEAR_FIRST,
)}`,
),
value: DateFormat.YEAR_FIRST,
},
]}
@@ -50,18 +50,21 @@ export const DateTimeSettingsTimeFormatSelect = ({
dropdownWidthAuto
fullWidth
value={value}
pinnedOption={{
label: t`System settings`,
value: TimeFormat.SYSTEM,
contextualText: systemTimeFormatLabel,
}}
options={[
{
label: t`System Settings - ${systemTimeFormatLabel}`,
value: TimeFormat.SYSTEM,
},
{
label: t`24h - ${hour24Label}`,
label: t`24h`,
value: TimeFormat.HOUR_24,
contextualText: hour24Label,
},
{
label: t`12h - ${hour12Label}`,
label: t`12h`,
value: TimeFormat.HOUR_12,
contextualText: hour12Label,
},
]}
onChange={onChange}
@@ -3,7 +3,6 @@ import { findAvailableTimeZoneOption } from '@/localization/utils/findAvailableT
import { AVAILABLE_TIMEZONE_OPTIONS } from '@/settings/experience/constants/AvailableTimezoneOptions';
import { Select } from '@/ui/input/components/Select';
import { t } from '@lingui/core/macro';
import { isDefined } from 'twenty-shared/utils';
import { type SelectOption } from 'twenty-ui/input';
type DateTimeSettingsTimeZoneSelectProps = {
@@ -23,18 +22,15 @@ export const DateTimeSettingsTimeZoneSelect = ({
<Select
dropdownId="datetime-settings-time-zone"
label={t`Time zone`}
dropdownWidthAuto
dropdownWidth={480}
fullWidth
value={value}
options={[
{
label: isDefined(systemTimeZoneOption)
? t`System settings`.concat(` - ${systemTimeZoneOption.label}`)
: t`System settings`,
value: 'system',
},
...(AVAILABLE_TIMEZONE_OPTIONS as SelectOption<string>[]),
]}
pinnedOption={{
label: t`System settings`,
value: 'system',
contextualText: systemTimeZoneOption?.label,
}}
options={AVAILABLE_TIMEZONE_OPTIONS as SelectOption<string>[]}
onChange={onChange}
withSearchInput
/>
@@ -46,26 +46,31 @@ export const NumberFormatSelect = ({
dropdownWidthAuto
fullWidth
value={value}
pinnedOption={{
label: t`System settings`,
value: NumberFormat.SYSTEM,
contextualText: systemNumberFormatLabel,
}}
options={[
{
label: t`System Settings - ${systemNumberFormatLabel}`,
value: NumberFormat.SYSTEM,
},
{
label: t`Commas and dot - ${commasAndDotExample}`,
label: t`Commas and dot`,
value: NumberFormat.COMMAS_AND_DOT,
contextualText: commasAndDotExample,
},
{
label: t`Spaces and comma - ${spacesAndCommaExample}`,
label: t`Spaces and comma`,
value: NumberFormat.SPACES_AND_COMMA,
contextualText: spacesAndCommaExample,
},
{
label: t`Dots and comma - ${dotsAndCommaExample}`,
label: t`Dots and comma`,
value: NumberFormat.DOTS_AND_COMMA,
contextualText: dotsAndCommaExample,
},
{
label: t`Apostrophe and dot - ${apostropheAndDotExample}`,
label: t`Apostrophe and dot`,
value: NumberFormat.APOSTROPHE_AND_DOT,
contextualText: apostropheAndDotExample,
},
]}
onChange={onChange}
@@ -49,9 +49,11 @@ export type SelectProps<Value extends SelectValue> = {
value?: Value;
withSearchInput?: boolean;
needIconCheck?: boolean;
pinnedOption?: SelectOption<Value>;
callToActionButton?: CallToActionButton;
dropdownOffset?: DropdownOffset;
hasRightElement?: boolean;
showContextualTextInControl?: boolean;
};
const StyledContainer = styled.div<{ fullWidth?: boolean }>`
@@ -88,15 +90,21 @@ export const Select = <Value extends SelectValue>({
value,
withSearchInput,
needIconCheck,
pinnedOption,
callToActionButton,
dropdownOffset,
hasRightElement,
showContextualTextInControl = true,
}: SelectProps<Value>) => {
const selectContainerRef = useRef<HTMLDivElement>(null);
const [searchInputValue, setSearchInputValue] = useState('');
const selectedOption = useMemo(() => {
if (isDefined(pinnedOption) && pinnedOption.value === value) {
return pinnedOption;
}
const fromMatchingOption = options.find(
({ value: optionValue }) => optionValue === value,
);
@@ -114,7 +122,7 @@ export const Select = <Value extends SelectValue>({
}
return null;
}, [emptyOption, options, value]);
}, [emptyOption, options, pinnedOption, value]);
const filteredOptions = useMemo(
() =>
@@ -129,6 +137,7 @@ export const Select = <Value extends SelectValue>({
const isDisabled =
disabledFromProps ||
(options.length <= 1 &&
!isDefined(pinnedOption) &&
!isDefined(callToActionButton) &&
(!isDefined(emptyOption) || selectedOption !== emptyOption));
@@ -148,13 +157,26 @@ export const Select = <Value extends SelectValue>({
const { setSelectedItemId } = useSelectableList(dropdownId);
const controlSelectedOption = useMemo(() => {
if (!isDefined(selectedOption) || showContextualTextInControl) {
return selectedOption;
}
const { contextualText: _, ...rest } = selectedOption;
return rest;
}, [selectedOption, showContextualTextInControl]);
const handleDropdownOpen = () => {
if (isDefined(selectedOption) && !isNonEmptyString(searchInputValue)) {
setSelectedItemId(selectedOption.label);
if (
isDefined(controlSelectedOption) &&
!isNonEmptyString(searchInputValue)
) {
setSelectedItemId(controlSelectedOption.label);
}
};
if (!isDefined(selectedOption)) {
if (!isDefined(controlSelectedOption)) {
return <></>;
}
@@ -169,7 +191,7 @@ export const Select = <Value extends SelectValue>({
{isNonEmptyString(label) && <StyledLabel>{label}</StyledLabel>}
{isDisabled ? (
<SelectControl
selectedOption={selectedOption}
selectedOption={controlSelectedOption}
isDisabled={isDisabled}
selectSizeVariant={selectSizeVariant}
hasRightElement={hasRightElement}
@@ -182,7 +204,7 @@ export const Select = <Value extends SelectValue>({
onOpen={handleDropdownOpen}
clickableComponent={
<SelectControl
selectedOption={selectedOption}
selectedOption={controlSelectedOption}
isDisabled={isDisabled}
selectSizeVariant={selectSizeVariant}
hasRightElement={hasRightElement}
@@ -200,6 +222,27 @@ export const Select = <Value extends SelectValue>({
{withSearchInput === true && isNonEmptyArray(filteredOptions) && (
<DropdownMenuSeparator />
)}
{isDefined(pinnedOption) && (
<DropdownMenuItemsContainer scrollable={false}>
<MenuItemSelect
LeftIcon={pinnedOption.Icon}
text={pinnedOption.label}
contextualText={pinnedOption.contextualText}
selected={
controlSelectedOption.value === pinnedOption.value
}
needIconCheck={needIconCheck}
onClick={() => {
onChange?.(pinnedOption.value);
onBlur?.();
closeDropdown(dropdownId);
}}
/>
</DropdownMenuItemsContainer>
)}
{isDefined(pinnedOption) && isNonEmptyArray(filteredOptions) && (
<DropdownMenuSeparator />
)}
{isNonEmptyArray(filteredOptions) && (
<DropdownMenuItemsContainer hasMaxHeight>
<SelectableList
@@ -220,7 +263,10 @@ export const Select = <Value extends SelectValue>({
<MenuItemSelect
LeftIcon={option.Icon}
text={option.label}
selected={selectedOption.value === option.value}
contextualText={option.contextualText}
selected={
controlSelectedOption.value === option.value
}
focused={selectedItemId === option.label}
needIconCheck={needIconCheck}
onClick={() => {
@@ -94,7 +94,13 @@ export const SelectControl = ({
stroke={theme.icon.stroke.sm}
/>
) : null}
<OverflowingTextWithTooltip text={selectedOption.label} />
<OverflowingTextWithTooltip
text={
selectedOption.contextualText
? `${selectedOption.label} · ${selectedOption.contextualText}`
: selectedOption.label
}
/>
<StyledIconChevronDownWrapper disabled={isDisabled}>
<IconChevronDown size={theme.icon.size.md} />
</StyledIconChevronDownWrapper>
@@ -1,8 +1,11 @@
import { useState } from 'react';
import { styled } from '@linaria/react';
import { DEFAULT_FAST_MODEL } from '@/ai/constants/DefaultFastModel';
import { DEFAULT_SMART_MODEL } from '@/ai/constants/DefaultSmartModel';
import {
AUTO_SELECT_FAST_MODEL_ID,
AUTO_SELECT_SMART_MODEL_ID,
} from 'twenty-shared/constants';
import { useWorkspaceAiModelAvailability } from '@/ai/hooks/useWorkspaceAiModelAvailability';
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { aiModelsState } from '@/client-config/states/aiModelsState';
@@ -51,46 +54,44 @@ export const SettingsAIModelsTab = () => {
const currentSmartModel = currentWorkspace?.smartModel;
const currentFastModel = currentWorkspace?.fastModel;
const buildVirtualModelOption = (virtualModelId: string) => {
const virtualModel = aiModels.find(
(model) => model.modelId === virtualModelId,
const buildPinnedOption = (autoSelectModelId: string) => {
const autoSelectEntry = aiModels.find(
(model) => model.modelId === autoSelectModelId,
);
return virtualModel
? {
value: virtualModelId,
label: virtualModel.label,
Icon: IconTwentyStar,
}
: null;
};
const smartAutoOption = buildVirtualModelOption(DEFAULT_SMART_MODEL);
const fastAutoOption = buildVirtualModelOption(DEFAULT_FAST_MODEL);
const modelOptions = enabledModels.map((model) => {
const residencyFlag = model.dataResidency
? ` ${getDataResidencyDisplay(model.dataResidency)}`
: '';
if (!autoSelectEntry) {
return undefined;
}
return {
value: model.modelId,
label: `${model.label}${residencyFlag}`,
Icon: getModelIcon(model.modelFamily, model.providerName),
value: autoSelectModelId,
label: autoSelectEntry.label,
Icon: getModelIcon(
autoSelectEntry.modelFamily,
autoSelectEntry.providerName,
),
contextualText: t`Best`,
};
});
};
const smartModelOptions = [...modelOptions];
const smartPinnedOption = buildPinnedOption(AUTO_SELECT_SMART_MODEL_ID);
const fastPinnedOption = buildPinnedOption(AUTO_SELECT_FAST_MODEL_ID);
if (smartAutoOption !== null) {
smartModelOptions.unshift(smartAutoOption);
}
const buildModelOptions = () =>
enabledModels.map((model) => {
const residencyFlag = model.dataResidency
? ` ${getDataResidencyDisplay(model.dataResidency)}`
: '';
const fastModelOptions = [...modelOptions];
return {
value: model.modelId,
label: `${model.label}${residencyFlag}`,
Icon: getModelIcon(model.modelFamily, model.providerName),
};
});
if (fastAutoOption !== null) {
fastModelOptions.unshift(fastAutoOption);
}
const smartModelOptions = buildModelOptions();
const fastModelOptions = buildModelOptions();
const handleModelFieldChange = async (
field: 'smartModel' | 'fastModel',
@@ -241,6 +242,7 @@ export const SettingsAIModelsTab = () => {
value={currentSmartModel}
onChange={(value) => handleModelFieldChange('smartModel', value)}
options={smartModelOptions}
pinnedOption={smartPinnedOption}
selectSizeVariant="small"
dropdownWidth={GenericDropdownContentWidth.ExtraLarge}
/>
@@ -255,6 +257,7 @@ export const SettingsAIModelsTab = () => {
value={currentFastModel}
onChange={(value) => handleModelFieldChange('fastModel', value)}
options={fastModelOptions}
pinnedOption={fastPinnedOption}
selectSizeVariant="small"
dropdownWidth={GenericDropdownContentWidth.ExtraLarge}
/>
@@ -2,7 +2,6 @@ import { useMemo } from 'react';
import { detectCalendarStartDay } from '@/localization/utils/detection/detectCalendarStartDay';
import { Select } from '@/ui/input/components/Select';
import { type DayNameWithIndex } from '@/ui/input/components/internal/date/types/DayNameWithIndex';
import { t } from '@lingui/core/macro';
import { CalendarStartDay } from 'twenty-shared/constants';
import { type SelectOption } from 'twenty-ui/input';
@@ -18,29 +17,21 @@ export const DateTimeSettingsCalendarStartDaySelect = ({
}: DateTimeSettingsCalendarStartDaySelectProps) => {
const systemCalendarStartDay = CalendarStartDay[detectCalendarStartDay()];
const options: SelectOption<CalendarStartDay>[] = useMemo(() => {
const systemDayLabel =
systemCalendarStartDay === CalendarStartDay.SUNDAY
? t`System settings - Sunday`
: systemCalendarStartDay === CalendarStartDay.MONDAY
? t`System settings - Monday`
: t`System settings - Saturday`;
const systemDayContextualText =
systemCalendarStartDay === CalendarStartDay.SUNDAY
? t`Sunday`
: systemCalendarStartDay === CalendarStartDay.MONDAY
? t`Monday`
: t`Saturday`;
const allowedDaysWeek: DayNameWithIndex[] = [
{
day: systemDayLabel,
index: CalendarStartDay.SYSTEM,
},
{ day: t`Sunday`, index: CalendarStartDay.SUNDAY },
{ day: t`Monday`, index: CalendarStartDay.MONDAY },
{ day: t`Saturday`, index: CalendarStartDay.SATURDAY },
];
return allowedDaysWeek.map(({ day, index }) => ({
label: day,
value: index as CalendarStartDay,
}));
}, [systemCalendarStartDay]);
const options: SelectOption<CalendarStartDay>[] = useMemo(
() => [
{ label: t`Sunday`, value: CalendarStartDay.SUNDAY },
{ label: t`Monday`, value: CalendarStartDay.MONDAY },
{ label: t`Saturday`, value: CalendarStartDay.SATURDAY },
],
[],
);
return (
<Select
@@ -50,6 +41,11 @@ export const DateTimeSettingsCalendarStartDaySelect = ({
fullWidth
dropdownWidthAuto
value={value}
pinnedOption={{
label: t`System settings`,
value: CalendarStartDay.SYSTEM,
contextualText: systemDayContextualText,
}}
options={options}
onChange={onChange}
/>
@@ -1,5 +1,7 @@
import { DEFAULT_FAST_MODEL } from '@/ai/constants/DefaultFastModel';
import { DEFAULT_SMART_MODEL } from '@/ai/constants/DefaultSmartModel';
import {
AUTO_SELECT_FAST_MODEL_ID,
AUTO_SELECT_SMART_MODEL_ID,
} from 'twenty-shared/constants';
import { type CurrentUserWorkspace } from '@/auth/states/currentUserWorkspaceState';
import { CUSTOM_WORKSPACE_APPLICATION_MOCK } from '@/object-metadata/hooks/__tests__/constants/CustomWorkspaceApplicationMock.test.constant';
import { type WorkspaceMember } from '@/workspace-member/types/WorkspaceMember';
@@ -86,8 +88,8 @@ export const mockCurrentWorkspace = {
updatedAt: '2023-04-26T10:23:42.33625+00:00',
metadataVersion: 1,
trashRetentionDays: 14,
fastModel: DEFAULT_FAST_MODEL,
smartModel: DEFAULT_SMART_MODEL,
fastModel: AUTO_SELECT_FAST_MODEL_ID,
smartModel: AUTO_SELECT_SMART_MODEL_ID,
routerModel: 'auto',
enabledAiModelIds: [],
useRecommendedModels: true,
@@ -6,7 +6,7 @@ export class MigrateModelIdsToCompositeFormat1773900000000
name = 'MigrateModelIdsToCompositeFormat1773900000000';
public async up(queryRunner: QueryRunner): Promise<void> {
// Reset workspace model columns to sentinel defaults.
// Reset workspace model columns to auto-select placeholders.
// The runtime resolves these dynamically from admin preferences.
await queryRunner.query(
`UPDATE "core"."workspace"
@@ -25,7 +25,7 @@ export class MigrateModelIdsToCompositeFormat1773900000000
OR array_length("enabledAiModelIds", 1) > 0`,
);
// Reset agent model IDs to the sentinel so they fall back to workspace defaults
// Reset agent model IDs to the auto-select placeholder so they fall back to workspace defaults
await queryRunner.query(
`UPDATE "core"."agent"
SET "modelId" = 'default-smart-model'
@@ -34,6 +34,6 @@ export class MigrateModelIdsToCompositeFormat1773900000000
}
public async down(_queryRunner: QueryRunner): Promise<void> {
// No reversal needed — sentinel defaults are safe to leave in place.
// No reversal needed — auto-select placeholders are safe to leave in place.
}
}
@@ -1,6 +1,6 @@
import { type AgentManifest } from 'twenty-shared/application';
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 { type UniversalFlatAgent } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-agent.type';
@@ -21,7 +21,7 @@ export const fromAgentManifestToUniversalFlatAgent = ({
icon: agentManifest.icon ?? null,
description: agentManifest.description ?? null,
prompt: agentManifest.prompt,
modelId: (agentManifest.modelId as ModelId) ?? DEFAULT_SMART_MODEL,
modelId: (agentManifest.modelId as ModelId) ?? AUTO_SELECT_SMART_MODEL_ID,
responseFormat: { type: 'text' },
modelConfiguration: null,
evaluationInputs: [],
@@ -20,8 +20,10 @@ import { DomainServerConfigService } from 'src/engine/core-modules/domain/domain
import { PUBLIC_FEATURE_FLAGS } from 'src/engine/core-modules/feature-flag/constants/public-feature-flag.const';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { convertDollarsToBillingCredits } from 'src/engine/metadata-modules/ai/ai-billing/utils/convert-dollars-to-billing-credits.util';
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';
import {
AUTO_SELECT_FAST_MODEL_ID,
AUTO_SELECT_SMART_MODEL_ID,
} from 'twenty-shared/constants';
import { MODEL_FAMILY_LABELS } from 'src/engine/metadata-modules/ai/ai-models/constants/model-family-labels.const';
import { AiModelRegistryService } from 'src/engine/metadata-modules/ai/ai-models/services/ai-model-registry.service';
@@ -107,10 +109,6 @@ export class ClientConfigService {
this.aiModelRegistryService.getDefaultSpeedModel();
const defaultSpeedModelConfig =
this.aiModelRegistryService.getModelConfig(defaultSpeedModel?.modelId);
const defaultSpeedModelLabel =
defaultSpeedModelConfig?.label ||
defaultSpeedModel?.modelId ||
'Default';
const defaultPerformanceModel =
this.aiModelRegistryService.getDefaultPerformanceModel();
@@ -118,23 +116,29 @@ export class ClientConfigService {
this.aiModelRegistryService.getModelConfig(
defaultPerformanceModel?.modelId,
);
const defaultPerformanceModelLabel =
defaultPerformanceModelConfig?.label ||
defaultPerformanceModel?.modelId ||
'Default';
aiModels.unshift(
{
modelId: DEFAULT_SMART_MODEL,
label: `Best (${defaultPerformanceModelLabel})`,
sdkPackage: null,
modelId: AUTO_SELECT_SMART_MODEL_ID,
label:
defaultPerformanceModelConfig?.label ||
defaultPerformanceModel?.modelId ||
'Default',
modelFamily: defaultPerformanceModelConfig?.modelFamily,
providerName: defaultPerformanceModel?.providerName,
sdkPackage: defaultPerformanceModel?.sdkPackage ?? null,
inputCostPerMillionTokensInCredits: 0,
outputCostPerMillionTokensInCredits: 0,
},
{
modelId: DEFAULT_FAST_MODEL,
label: `Best (${defaultSpeedModelLabel})`,
sdkPackage: null,
modelId: AUTO_SELECT_FAST_MODEL_ID,
label:
defaultSpeedModelConfig?.label ||
defaultSpeedModel?.modelId ||
'Default',
modelFamily: defaultSpeedModelConfig?.modelFamily,
providerName: defaultSpeedModel?.providerName,
sdkPackage: defaultSpeedModel?.sdkPackage ?? null,
inputCostPerMillionTokensInCredits: 0,
outputCostPerMillionTokensInCredits: 0,
},
@@ -34,8 +34,10 @@ import { PublicDomainEntity } from 'src/engine/core-modules/public-domain/public
import { WorkspaceSSOIdentityProviderEntity } from 'src/engine/core-modules/sso/workspace-sso-identity-provider.entity';
import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
import { AgentEntity } from 'src/engine/metadata-modules/ai/ai-agent/entities/agent.entity';
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';
import {
AUTO_SELECT_FAST_MODEL_ID,
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 { RoleDTO } from 'src/engine/metadata-modules/role/dtos/role.dto';
import { ViewFieldDTO } from 'src/engine/metadata-modules/view-field/dtos/view-field.dto';
@@ -301,11 +303,19 @@ export class WorkspaceEntity {
version: string | null;
@Field(() => String, { nullable: false })
@Column({ type: 'varchar', nullable: false, default: DEFAULT_FAST_MODEL })
@Column({
type: 'varchar',
nullable: false,
default: AUTO_SELECT_FAST_MODEL_ID,
})
fastModel: ModelId;
@Field(() => String, { nullable: false })
@Column({ type: 'varchar', nullable: false, default: DEFAULT_SMART_MODEL })
@Column({
type: 'varchar',
nullable: false,
default: AUTO_SELECT_SMART_MODEL_ID,
})
smartModel: ModelId;
@Field(() => String, { nullable: true })
@@ -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;
}
@@ -1,4 +1,4 @@
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 FlatAgent } from 'src/engine/metadata-modules/flat-agent/types/flat-agent.type';
import { type AllStandardAgentName } from 'src/engine/workspace-manager/twenty-standard-application/types/all-standard-agent-name.type';
import {
@@ -40,7 +40,7 @@ Response format:
- Use markdown for readability
Always base answers on official Twenty documentation. Be patient and helpful.`,
modelId: DEFAULT_SMART_MODEL,
modelId: AUTO_SELECT_SMART_MODEL_ID,
responseFormat: { type: 'text' },
isCustom: false,
modelConfiguration: {},
@@ -19,7 +19,7 @@ import { getFlatFieldsFromFlatObjectMetadata } from 'src/engine/api/graphql/work
import { type WorkflowStepPositionInput } from 'src/engine/core-modules/workflow/dtos/update-workflow-step-position.input';
import { AiAgentRoleService } from 'src/engine/metadata-modules/ai/ai-agent-role/ai-agent-role.service';
import { AgentService } from 'src/engine/metadata-modules/ai/ai-agent/agent.service';
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 { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
import { LogicFunctionFromSourceService } from 'src/engine/metadata-modules/logic-function/services/logic-function-from-source.service';
import { findFlatLogicFunctionOrThrow } from 'src/engine/metadata-modules/logic-function/utils/find-flat-logic-function-or-throw.util';
@@ -446,7 +446,7 @@ export class WorkflowVersionStepOperationsWorkspaceService {
description: '',
prompt:
'You are a helpful AI assistant. Complete the task based on the workflow context.',
modelId: DEFAULT_SMART_MODEL,
modelId: AUTO_SELECT_SMART_MODEL_ID,
responseFormat: { type: 'text' },
isCustom: true,
},
@@ -9,7 +9,7 @@ import { type WorkflowAction } from 'src/modules/workflow/workflow-executor/inte
import { AgentAsyncExecutorService } from 'src/engine/metadata-modules/ai/ai-agent-execution/services/agent-async-executor.service';
import { AgentEntity } from 'src/engine/metadata-modules/ai/ai-agent/entities/agent.entity';
import { AiBillingService } from 'src/engine/metadata-modules/ai/ai-billing/services/ai-billing.service';
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 {
WorkflowStepExecutorException,
WorkflowStepExecutorExceptionCode,
@@ -90,7 +90,7 @@ export class AiAgentWorkflowAction implements WorkflowAction {
});
await this.aiBillingService.calculateAndBillUsage(
agent?.modelId ?? DEFAULT_SMART_MODEL,
agent?.modelId ?? AUTO_SELECT_SMART_MODEL_ID,
{ usage, cacheCreationTokens },
workspaceId,
agent?.id || null,
@@ -0,0 +1 @@
export const AUTO_SELECT_FAST_MODEL_ID = 'default-fast-model' as const;
@@ -0,0 +1 @@
export const AUTO_SELECT_SMART_MODEL_ID = 'default-smart-model' as const;
@@ -7,6 +7,8 @@
* |___/
*/
export { AUTO_SELECT_FAST_MODEL_ID } from './AutoSelectFastModelId';
export { AUTO_SELECT_SMART_MODEL_ID } from './AutoSelectSmartModelId';
export { BACKEND_BATCH_REQUEST_MAX_COUNT } from './BackendBatchRequestMaxCount';
export { CalendarStartDay } from './CalendarStartDay';
export { COMPOSITE_FIELD_TYPE_SUB_FIELDS_NAMES } from './CompositeFieldTypeSubFieldsNames';
@@ -143,6 +143,7 @@ export {
getLogoUrlFromDomainName,
} from './image/getLogoUrlFromDomainName';
export { getUniqueConstraintsFields } from './indexMetadata/getUniqueConstraintsFields';
export { isAutoSelectModelId } from './isAutoSelectModelId';
export { fastDeepEqual } from './json/fast-deep-equal';
export { getAppPath } from './navigation/getAppPath';
export { getSettingsPath } from './navigation/getSettingsPath';
@@ -0,0 +1,6 @@
import { AUTO_SELECT_FAST_MODEL_ID } from '../constants/AutoSelectFastModelId';
import { AUTO_SELECT_SMART_MODEL_ID } from '../constants/AutoSelectSmartModelId';
export const isAutoSelectModelId = (modelId: string): boolean =>
modelId === AUTO_SELECT_FAST_MODEL_ID ||
modelId === AUTO_SELECT_SMART_MODEL_ID;
@@ -23,6 +23,10 @@ const StyledIconButton = styled.button<{
color 0.1s ease-in-out,
background 0.1s ease-in-out;
&:hover:not(:disabled) {
background: ${themeCssVariables.color.blue10};
}
&:disabled {
background: ${themeCssVariables.background.quaternary};
color: ${themeCssVariables.font.color.tertiary};
@@ -10,4 +10,5 @@ export type SelectOption<
value: Value;
disabled?: boolean;
color?: ThemeColor | 'transparent';
contextualText?: string;
};