[AI] new model tab design (#19384)

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

<img width="1839" height="1316" alt="CleanShot 2026-04-07 at 15 22 38"
src="https://github.com/user-attachments/assets/d8f6048c-4e0f-425f-b7ea-4913d116020e"
/>
This commit is contained in:
nitin
2026-04-09 19:45:43 +05:30
committed by GitHub
parent 74e26ae635
commit 50c5a1a8de
37 changed files with 873 additions and 570 deletions
@@ -1634,12 +1634,15 @@ type ClientAIModelConfig {
modelFamily: ModelFamily
modelFamilyLabel: String
sdkPackage: String
inputCostPerMillionTokensInCredits: Float!
outputCostPerMillionTokensInCredits: Float!
inputCostPerMillionTokens: Float
outputCostPerMillionTokens: Float
nativeCapabilities: NativeModelCapabilities
isDeprecated: Boolean
isRecommended: Boolean
providerName: String
providerLabel: String
contextWindowTokens: Float
maxOutputTokens: Float
dataResidency: String
}
@@ -3608,7 +3611,9 @@ type Mutation {
userLookupAdminPanel(userIdentifier: String!): UserLookup!
updateWorkspaceFeatureFlag(workspaceId: UUID!, featureFlag: String!, value: Boolean!): Boolean!
setAdminAiModelEnabled(modelId: String!, enabled: Boolean!): Boolean!
setAdminAiModelsEnabled(modelIds: [String!]!, enabled: Boolean!): Boolean!
setAdminAiModelRecommended(modelId: String!, recommended: Boolean!): Boolean!
setAdminAiModelsRecommended(modelIds: [String!]!, recommended: Boolean!): Boolean!
setAdminDefaultAiModel(role: AiModelRole!, modelId: String!): Boolean!
createDatabaseConfigVariable(key: String!, value: JSON!): Boolean!
updateDatabaseConfigVariable(key: String!, value: JSON!): Boolean!
@@ -1345,12 +1345,15 @@ export interface ClientAIModelConfig {
modelFamily?: ModelFamily
modelFamilyLabel?: Scalars['String']
sdkPackage?: Scalars['String']
inputCostPerMillionTokensInCredits: Scalars['Float']
outputCostPerMillionTokensInCredits: Scalars['Float']
inputCostPerMillionTokens?: Scalars['Float']
outputCostPerMillionTokens?: Scalars['Float']
nativeCapabilities?: NativeModelCapabilities
isDeprecated?: Scalars['Boolean']
isRecommended?: Scalars['Boolean']
providerName?: Scalars['String']
providerLabel?: Scalars['String']
contextWindowTokens?: Scalars['Float']
maxOutputTokens?: Scalars['Float']
dataResidency?: Scalars['String']
__typename: 'ClientAIModelConfig'
}
@@ -3063,7 +3066,9 @@ export interface Mutation {
userLookupAdminPanel: UserLookup
updateWorkspaceFeatureFlag: Scalars['Boolean']
setAdminAiModelEnabled: Scalars['Boolean']
setAdminAiModelsEnabled: Scalars['Boolean']
setAdminAiModelRecommended: Scalars['Boolean']
setAdminAiModelsRecommended: Scalars['Boolean']
setAdminDefaultAiModel: Scalars['Boolean']
createDatabaseConfigVariable: Scalars['Boolean']
updateDatabaseConfigVariable: Scalars['Boolean']
@@ -4514,12 +4519,15 @@ export interface ClientAIModelConfigGenqlSelection{
modelFamily?: boolean | number
modelFamilyLabel?: boolean | number
sdkPackage?: boolean | number
inputCostPerMillionTokensInCredits?: boolean | number
outputCostPerMillionTokensInCredits?: boolean | number
inputCostPerMillionTokens?: boolean | number
outputCostPerMillionTokens?: boolean | number
nativeCapabilities?: NativeModelCapabilitiesGenqlSelection
isDeprecated?: boolean | number
isRecommended?: boolean | number
providerName?: boolean | number
providerLabel?: boolean | number
contextWindowTokens?: boolean | number
maxOutputTokens?: boolean | number
dataResidency?: boolean | number
__typename?: boolean | number
__scalar?: boolean | number
@@ -6371,7 +6379,9 @@ export interface MutationGenqlSelection{
userLookupAdminPanel?: (UserLookupGenqlSelection & { __args: {userIdentifier: Scalars['String']} })
updateWorkspaceFeatureFlag?: { __args: {workspaceId: Scalars['UUID'], featureFlag: Scalars['String'], value: Scalars['Boolean']} }
setAdminAiModelEnabled?: { __args: {modelId: Scalars['String'], enabled: Scalars['Boolean']} }
setAdminAiModelsEnabled?: { __args: {modelIds: Scalars['String'][], enabled: Scalars['Boolean']} }
setAdminAiModelRecommended?: { __args: {modelId: Scalars['String'], recommended: Scalars['Boolean']} }
setAdminAiModelsRecommended?: { __args: {modelIds: Scalars['String'][], recommended: Scalars['Boolean']} }
setAdminDefaultAiModel?: { __args: {role: AiModelRole, modelId: Scalars['String']} }
createDatabaseConfigVariable?: { __args: {key: Scalars['String'], value: Scalars['JSON']} }
updateDatabaseConfigVariable?: { __args: {key: Scalars['String'], value: Scalars['JSON']} }
@@ -3299,10 +3299,10 @@ export default {
"sdkPackage": [
1
],
"inputCostPerMillionTokensInCredits": [
"inputCostPerMillionTokens": [
11
],
"outputCostPerMillionTokensInCredits": [
"outputCostPerMillionTokens": [
11
],
"nativeCapabilities": [
@@ -3317,6 +3317,15 @@ export default {
"providerName": [
1
],
"providerLabel": [
1
],
"contextWindowTokens": [
11
],
"maxOutputTokens": [
11
],
"dataResidency": [
1
],
@@ -9020,6 +9029,19 @@ export default {
]
}
],
"setAdminAiModelsEnabled": [
6,
{
"modelIds": [
1,
"[String!]!"
],
"enabled": [
6,
"Boolean!"
]
}
],
"setAdminAiModelRecommended": [
6,
{
@@ -9033,6 +9055,19 @@ export default {
]
}
],
"setAdminAiModelsRecommended": [
6,
{
"modelIds": [
1,
"[String!]!"
],
"recommended": [
6,
"Boolean!"
]
}
],
"setAdminDefaultAiModel": [
6,
{
@@ -867,16 +867,19 @@ export type CheckUserExist = {
export type ClientAiModelConfig = {
__typename?: 'ClientAIModelConfig';
contextWindowTokens?: Maybe<Scalars['Float']>;
dataResidency?: Maybe<Scalars['String']>;
inputCostPerMillionTokensInCredits: Scalars['Float'];
inputCostPerMillionTokens?: Maybe<Scalars['Float']>;
isDeprecated?: Maybe<Scalars['Boolean']>;
isRecommended?: Maybe<Scalars['Boolean']>;
label: Scalars['String'];
maxOutputTokens?: Maybe<Scalars['Float']>;
modelFamily?: Maybe<ModelFamily>;
modelFamilyLabel?: Maybe<Scalars['String']>;
modelId: Scalars['String'];
nativeCapabilities?: Maybe<NativeModelCapabilities>;
outputCostPerMillionTokensInCredits: Scalars['Float'];
outputCostPerMillionTokens?: Maybe<Scalars['Float']>;
providerLabel?: Maybe<Scalars['String']>;
providerName?: Maybe<Scalars['String']>;
sdkPackage?: Maybe<Scalars['String']>;
};
@@ -2665,6 +2668,8 @@ export type Mutation = {
sendInvitations: SendInvitations;
setAdminAiModelEnabled: Scalars['Boolean'];
setAdminAiModelRecommended: Scalars['Boolean'];
setAdminAiModelsEnabled: Scalars['Boolean'];
setAdminAiModelsRecommended: Scalars['Boolean'];
setAdminDefaultAiModel: Scalars['Boolean'];
setEnterpriseKey: EnterpriseLicenseInfoDto;
setMaintenanceMode: Scalars['Boolean'];
@@ -3412,6 +3417,18 @@ export type MutationSetAdminAiModelRecommendedArgs = {
};
export type MutationSetAdminAiModelsEnabledArgs = {
enabled: Scalars['Boolean'];
modelIds: Array<Scalars['String']>;
};
export type MutationSetAdminAiModelsRecommendedArgs = {
modelIds: Array<Scalars['String']>;
recommended: Scalars['Boolean'];
};
export type MutationSetAdminDefaultAiModelArgs = {
modelId: Scalars['String'];
role: AiModelRole;
@@ -7223,6 +7240,22 @@ export type SetAdminAiModelRecommendedMutationVariables = Exact<{
export type SetAdminAiModelRecommendedMutation = { __typename?: 'Mutation', setAdminAiModelRecommended: boolean };
export type SetAdminAiModelsEnabledMutationVariables = Exact<{
modelIds: Array<Scalars['String']> | Scalars['String'];
enabled: Scalars['Boolean'];
}>;
export type SetAdminAiModelsEnabledMutation = { __typename?: 'Mutation', setAdminAiModelsEnabled: boolean };
export type SetAdminAiModelsRecommendedMutationVariables = Exact<{
modelIds: Array<Scalars['String']> | Scalars['String'];
recommended: Scalars['Boolean'];
}>;
export type SetAdminAiModelsRecommendedMutation = { __typename?: 'Mutation', setAdminAiModelsRecommended: boolean };
export type SetAdminDefaultAiModelMutationVariables = Exact<{
role: AiModelRole;
modelId: Scalars['String'];
@@ -8479,6 +8512,8 @@ export const RemoveAiProviderDocument = {"kind":"Document","definitions":[{"kind
export const RemoveModelFromProviderDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"RemoveModelFromProvider"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"providerName"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"modelName"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"removeModelFromProvider"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"providerName"},"value":{"kind":"Variable","name":{"kind":"Name","value":"providerName"}}},{"kind":"Argument","name":{"kind":"Name","value":"modelName"},"value":{"kind":"Variable","name":{"kind":"Name","value":"modelName"}}}]}]}}]} as unknown as DocumentNode<RemoveModelFromProviderMutation, RemoveModelFromProviderMutationVariables>;
export const SetAdminAiModelEnabledDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"SetAdminAiModelEnabled"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"modelId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"enabled"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Boolean"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"setAdminAiModelEnabled"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"modelId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"modelId"}}},{"kind":"Argument","name":{"kind":"Name","value":"enabled"},"value":{"kind":"Variable","name":{"kind":"Name","value":"enabled"}}}]}]}}]} as unknown as DocumentNode<SetAdminAiModelEnabledMutation, SetAdminAiModelEnabledMutationVariables>;
export const SetAdminAiModelRecommendedDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"SetAdminAiModelRecommended"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"modelId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"recommended"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Boolean"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"setAdminAiModelRecommended"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"modelId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"modelId"}}},{"kind":"Argument","name":{"kind":"Name","value":"recommended"},"value":{"kind":"Variable","name":{"kind":"Name","value":"recommended"}}}]}]}}]} as unknown as DocumentNode<SetAdminAiModelRecommendedMutation, SetAdminAiModelRecommendedMutationVariables>;
export const SetAdminAiModelsEnabledDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"SetAdminAiModelsEnabled"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"modelIds"}},"type":{"kind":"NonNullType","type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"enabled"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Boolean"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"setAdminAiModelsEnabled"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"modelIds"},"value":{"kind":"Variable","name":{"kind":"Name","value":"modelIds"}}},{"kind":"Argument","name":{"kind":"Name","value":"enabled"},"value":{"kind":"Variable","name":{"kind":"Name","value":"enabled"}}}]}]}}]} as unknown as DocumentNode<SetAdminAiModelsEnabledMutation, SetAdminAiModelsEnabledMutationVariables>;
export const SetAdminAiModelsRecommendedDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"SetAdminAiModelsRecommended"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"modelIds"}},"type":{"kind":"NonNullType","type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"recommended"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Boolean"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"setAdminAiModelsRecommended"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"modelIds"},"value":{"kind":"Variable","name":{"kind":"Name","value":"modelIds"}}},{"kind":"Argument","name":{"kind":"Name","value":"recommended"},"value":{"kind":"Variable","name":{"kind":"Name","value":"recommended"}}}]}]}}]} as unknown as DocumentNode<SetAdminAiModelsRecommendedMutation, SetAdminAiModelsRecommendedMutationVariables>;
export const SetAdminDefaultAiModelDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"SetAdminDefaultAiModel"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"role"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"AiModelRole"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"modelId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"setAdminDefaultAiModel"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"role"},"value":{"kind":"Variable","name":{"kind":"Name","value":"role"}}},{"kind":"Argument","name":{"kind":"Name","value":"modelId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"modelId"}}}]}]}}]} as unknown as DocumentNode<SetAdminDefaultAiModelMutation, SetAdminDefaultAiModelMutationVariables>;
export const GetAdminAiModelsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetAdminAiModels"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"getAdminAiModels"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"defaultSmartModelId"}},{"kind":"Field","name":{"kind":"Name","value":"defaultFastModelId"}},{"kind":"Field","name":{"kind":"Name","value":"models"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"modelId"}},{"kind":"Field","name":{"kind":"Name","value":"label"}},{"kind":"Field","name":{"kind":"Name","value":"modelFamily"}},{"kind":"Field","name":{"kind":"Name","value":"sdkPackage"}},{"kind":"Field","name":{"kind":"Name","value":"isAvailable"}},{"kind":"Field","name":{"kind":"Name","value":"isAdminEnabled"}},{"kind":"Field","name":{"kind":"Name","value":"isDeprecated"}},{"kind":"Field","name":{"kind":"Name","value":"isRecommended"}},{"kind":"Field","name":{"kind":"Name","value":"contextWindowTokens"}},{"kind":"Field","name":{"kind":"Name","value":"maxOutputTokens"}},{"kind":"Field","name":{"kind":"Name","value":"inputCostPerMillionTokens"}},{"kind":"Field","name":{"kind":"Name","value":"outputCostPerMillionTokens"}},{"kind":"Field","name":{"kind":"Name","value":"providerName"}},{"kind":"Field","name":{"kind":"Name","value":"providerLabel"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"dataResidency"}}]}}]}}]}}]} as unknown as DocumentNode<GetAdminAiModelsQuery, GetAdminAiModelsQueryVariables>;
export const GetAdminAiUsageByWorkspaceDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetAdminAiUsageByWorkspace"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"periodStart"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"DateTime"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"periodEnd"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"DateTime"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"getAdminAiUsageByWorkspace"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"periodStart"},"value":{"kind":"Variable","name":{"kind":"Name","value":"periodStart"}}},{"kind":"Argument","name":{"kind":"Name","value":"periodEnd"},"value":{"kind":"Variable","name":{"kind":"Name","value":"periodEnd"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"key"}},{"kind":"Field","name":{"kind":"Name","value":"label"}},{"kind":"Field","name":{"kind":"Name","value":"creditsUsed"}}]}}]}}]} as unknown as DocumentNode<GetAdminAiUsageByWorkspaceQuery, GetAdminAiUsageByWorkspaceQueryVariables>;
@@ -5,7 +5,7 @@ import { type SelectOption } from 'twenty-ui/input';
import { useWorkspaceAiModelAvailability } from '@/ai/hooks/useWorkspaceAiModelAvailability';
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { aiModelsState } from '@/client-config/states/aiModelsState';
import { getModelIcon } from '@/settings/admin-panel/ai/utils/getModelIcon';
import { getModelIcon } from '@/settings/ai/utils/getModelIcon';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
type UseAiModelOptionsVariant = 'all' | 'pinned-default';
@@ -3,49 +3,25 @@ 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';
export const useWorkspaceAiModelAvailability = () => {
const aiModels = useAtomStateValue(aiModelsState);
const currentWorkspace = useAtomStateValue(currentWorkspaceState);
const useRecommendedModels = currentWorkspace?.useRecommendedModels ?? true;
const enabledAiModelIds = currentWorkspace?.enabledAiModelIds ?? [];
const isModelEnabled = (
modelId: string,
model?: ClientAiModelConfig,
): boolean => {
if (isAutoSelectModelId(modelId)) {
return true;
}
if (useRecommendedModels) {
return model?.isRecommended === true;
}
return enabledAiModelIds.includes(modelId);
};
const enabledAiModelIds = new Set(currentWorkspace?.enabledAiModelIds ?? []);
const realModels = aiModels.filter(
(model) => !isAutoSelectModelId(model.modelId) && !model.isDeprecated,
);
const enabledModels = realModels.filter((model) =>
isModelEnabled(model.modelId, model),
);
const allModelsWithAvailability = realModels.map((model) => ({
...model,
isEnabled: isModelEnabled(model.modelId, model),
}));
const enabledModels = useRecommendedModels
? realModels.filter((model) => model.isRecommended === true)
: realModels.filter((model) => enabledAiModelIds.has(model.modelId));
return {
isModelEnabled,
enabledModels,
realModels,
allModelsWithAvailability,
useRecommendedModels,
enabledAiModelIds,
};
};
@@ -10,17 +10,18 @@ import { themeCssVariables } from 'twenty-ui/theme-constants';
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { billingState } from '@/client-config/states/billingState';
import { useClientConfig } from '@/client-config/hooks/useClientConfig';
import { SettingsAdminAiModelsTable } from '@/settings/admin-panel/ai/components/SettingsAdminAiModelsTable';
import { SettingsAiModelsTable } from '@/settings/ai/components/SettingsAiModelsTable';
import { SettingsAdminAiProviderListCard } from '@/settings/admin-panel/ai/components/SettingsAdminAiProviderListCard';
import { AI_PROVIDER_SOURCE } from '@/settings/admin-panel/ai/constants/AiProviderSource';
import { SET_ADMIN_AI_MODEL_RECOMMENDED } from '@/settings/admin-panel/ai/graphql/mutations/setAdminAiModelRecommended';
import { SET_ADMIN_AI_MODELS_RECOMMENDED } from '@/settings/admin-panel/ai/graphql/mutations/setAdminAiModelsRecommended';
import { SET_ADMIN_DEFAULT_AI_MODEL } from '@/settings/admin-panel/ai/graphql/mutations/setAdminDefaultAiModel';
import { GET_ADMIN_AI_MODELS } from '@/settings/admin-panel/ai/graphql/queries/getAdminAiModels';
import { GET_ADMIN_AI_USAGE_BY_WORKSPACE } from '@/settings/admin-panel/ai/graphql/queries/getAdminAiUsageByWorkspace';
import { GET_AI_PROVIDERS } from '@/settings/admin-panel/ai/graphql/queries/getAiProviders';
import { type GetAiProvidersResult } from '@/settings/admin-panel/ai/types/GetAiProvidersResult';
import { getModelIcon } from '@/settings/admin-panel/ai/utils/getModelIcon';
import { parseProviderItems } from '@/settings/admin-panel/ai/utils/parseProviderItems';
import { getModelIcon } from '@/settings/ai/utils/getModelIcon';
import { SettingsAdminTabSkeletonLoader } from '@/settings/admin-panel/components/SettingsAdminTabSkeletonLoader';
import { SettingsEnterpriseFeatureGateCard } from '@/settings/components/SettingsEnterpriseFeatureGateCard';
import { SettingsOptionCardContentSelect } from '@/settings/components/SettingsOptions/SettingsOptionCardContentSelect';
@@ -62,7 +63,11 @@ export const SettingsAdminAI = () => {
const periodOptions = getPeriodOptions();
const usageDates = getPeriodDates(usagePeriod);
const { data, loading: isLoadingModels } = useQuery<{
const {
data,
loading: isLoadingModels,
refetch: refetchModels,
} = useQuery<{
getAdminAiModels: {
defaultSmartModelId?: string | null;
defaultFastModelId?: string | null;
@@ -71,6 +76,7 @@ export const SettingsAdminAI = () => {
}>(GET_ADMIN_AI_MODELS);
const [setModelRecommended] = useMutation(SET_ADMIN_AI_MODEL_RECOMMENDED);
const [setModelsRecommended] = useMutation(SET_ADMIN_AI_MODELS_RECOMMENDED);
const [setDefaultModel] = useMutation(SET_ADMIN_DEFAULT_AI_MODEL);
const { data: providersData, loading: isLoadingProviders } =
@@ -244,10 +250,35 @@ export const SettingsAdminAI = () => {
description={t`Select which models appear as recommended in the workspace model picker`}
/>
<SettingsAdminAiModelsTable
<SettingsAiModelsTable
models={enabledModels}
isChecked={(model) => model.isRecommended === true}
onToggle={handleRecommendedToggle}
checkedField="isRecommended"
onToggleAll={async (shouldCheckAll) => {
const modelIds = enabledModels
.filter(
(model) => (model.isRecommended === true) !== shouldCheckAll,
)
.map((model) => model.modelId);
if (modelIds.length === 0) return;
try {
await setModelsRecommended({
variables: {
modelIds,
recommended: shouldCheckAll,
},
});
} catch {
enqueueErrorSnackBar({
message: t`Failed to update model recommendations`,
});
} finally {
await refetchModels();
await refetchClientConfig();
}
}}
anchorPrefix="recommended-model-row"
/>
</Section>
@@ -1,149 +0,0 @@
import { styled } from '@linaria/react';
import { t } from '@lingui/core/macro';
import {
IconBolt,
IconCoins,
IconFileText,
IconFlag,
IconServer,
IconTag,
} from 'twenty-ui/display';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { SettingsAdminTableCard } from '@/settings/admin-panel/components/SettingsAdminTableCard';
import { getDataResidencyDisplay } from '@/settings/admin-panel/ai/utils/getDataResidencyDisplay';
import { getModelIcon } from '@/settings/admin-panel/ai/utils/getModelIcon';
import { type ModelFamily } from '~/generated-metadata/graphql';
import { formatNumber } from '~/utils/format/formatNumber';
const StyledNameValue = styled.span`
align-items: center;
display: flex;
gap: 4px;
`;
const StyledHoverCardWrapper = styled.div`
border-radius: ${themeCssVariables.border.radius.md};
box-shadow: ${themeCssVariables.boxShadow.strong};
overflow: hidden;
`;
type SettingsAdminAiModelHoverCardProps = {
label: string;
modelFamily?: ModelFamily | null;
providerName?: string | null;
providerLabel: string;
contextWindowTokens?: number | null;
maxOutputTokens?: number | null;
inputCostPerMillionTokens?: number | null;
outputCostPerMillionTokens?: number | null;
dataResidency?: string | null;
};
const formatCost = (
inputCost?: number | null,
outputCost?: number | null,
): string => {
if (inputCost == null && outputCost == null) {
return '—';
}
const parts: string[] = [];
if (inputCost != null) {
parts.push(`$${inputCost} in`);
}
if (outputCost != null) {
parts.push(`$${outputCost} out`);
}
return parts.join(' / ');
};
export const SettingsAdminAiModelHoverCard = ({
label,
modelFamily,
providerName,
providerLabel,
contextWindowTokens,
maxOutputTokens,
inputCostPerMillionTokens,
outputCostPerMillionTokens,
dataResidency,
}: SettingsAdminAiModelHoverCardProps) => {
const ModelIcon = getModelIcon(modelFamily, providerName);
const items = [
{
Icon: IconTag,
label: t`Name`,
value: (
<StyledNameValue>
<ModelIcon size={14} />
{label}
</StyledNameValue>
),
},
{
Icon: IconServer,
label: t`Provider`,
value: providerLabel || '—',
},
...(inputCostPerMillionTokens != null || outputCostPerMillionTokens != null
? [
{
Icon: IconCoins,
label: t`Cost / 1M`,
value: formatCost(
inputCostPerMillionTokens,
outputCostPerMillionTokens,
),
},
]
: []),
...(contextWindowTokens != null
? [
{
Icon: IconFileText,
label: t`Context`,
value: `${formatNumber(contextWindowTokens, {
abbreviate: true,
decimals: 1,
})} tokens`,
},
]
: []),
...(maxOutputTokens != null
? [
{
Icon: IconBolt,
label: t`Max output`,
value: `${formatNumber(maxOutputTokens, {
abbreviate: true,
decimals: 1,
})} tokens`,
},
]
: []),
...(dataResidency
? [
{
Icon: IconFlag,
label: t`Data residency`,
value: getDataResidencyDisplay(dataResidency),
},
]
: []),
];
return (
<StyledHoverCardWrapper>
<SettingsAdminTableCard
rounded
items={items}
gridAutoColumns="120px 1fr"
/>
</StyledHoverCardWrapper>
);
};
@@ -1,245 +0,0 @@
import { useContext, useState } from 'react';
import { css } from '@linaria/core';
import { styled } from '@linaria/react';
import { Trans } from '@lingui/react/macro';
import { isDefined } from 'twenty-shared/utils';
import { AppTooltip, IconTrash, TooltipDelay } from 'twenty-ui/display';
import { Checkbox, IconButton } from 'twenty-ui/input';
import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants';
import { SettingsAdminAiModelHoverCard } from '@/settings/admin-panel/ai/components/SettingsAdminAiModelHoverCard';
import { type AdminAiModelConfig } from '~/generated-metadata/graphql';
import { Table } from '@/ui/layout/table/components/Table';
import { TableBody } from '@/ui/layout/table/components/TableBody';
import { TableCell } from '@/ui/layout/table/components/TableCell';
import { TableHeader } from '@/ui/layout/table/components/TableHeader';
import { TableRow } from '@/ui/layout/table/components/TableRow';
import { getModelIcon } from '@/settings/admin-panel/ai/utils/getModelIcon';
const getProviderDisplayLabel = (
model: Pick<AdminAiModelConfig, 'providerLabel' | 'providerName'>,
): string => model.providerLabel ?? model.providerName ?? '';
const formatCost = (
model: Pick<
AdminAiModelConfig,
'inputCostPerMillionTokens' | 'outputCostPerMillionTokens'
>,
): string => {
const input = model.inputCostPerMillionTokens;
const output = model.outputCostPerMillionTokens;
if (!isDefined(input) && !isDefined(output)) {
return '—';
}
const formatValue = (value: number | null | undefined) =>
isDefined(value) ? `$${value}` : '—';
return `${formatValue(input)} / ${formatValue(output)}`;
};
type SecondaryColumn = 'provider' | 'cost';
const GRID_TEMPLATE_COLUMNS: Record<SecondaryColumn, string> = {
provider: '1fr 120px 40px',
cost: '1fr 140px 40px',
};
const GRID_TEMPLATE_COLUMNS_WITH_REMOVE: Record<SecondaryColumn, string> = {
provider: '1fr 120px 40px 32px',
cost: '1fr 140px 40px 32px',
};
const StyledModelNameCell = styled.div`
align-items: center;
display: flex;
gap: ${themeCssVariables.spacing[2]};
overflow: hidden;
`;
const StyledModelLabel = styled.span`
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
`;
const StyledDeprecatedSuffix = styled.span`
color: ${themeCssVariables.font.color.light};
`;
const hoverCardTooltipClass = css`
background-color: transparent !important;
border: none !important;
border-radius: 0 !important;
box-shadow: none !important;
padding: 0 !important;
`;
const sanitizeIdForSelector = (id: string): string =>
id.replace(/[^a-zA-Z0-9-_]/g, '_');
type SettingsAdminAiModelsTableProps = {
models: AdminAiModelConfig[];
onToggle: (modelId: string, currentValue: boolean) => void;
checkedField: 'isAdminEnabled' | 'isRecommended';
anchorPrefix: string;
showDisabledState?: boolean;
onRemove?: (model: AdminAiModelConfig) => void;
secondaryColumn?: SecondaryColumn;
};
export const SettingsAdminAiModelsTable = ({
models,
onToggle,
checkedField,
anchorPrefix,
showDisabledState = false,
onRemove,
secondaryColumn = 'provider',
}: SettingsAdminAiModelsTableProps) => {
const [hoveredModelId, setHoveredModelId] = useState<string | null>(null);
const { theme } = useContext(ThemeContext);
const hoveredModel = models.find((model) => model.modelId === hoveredModelId);
const hasRemove = isDefined(onRemove);
const gridColumns = hasRemove
? GRID_TEMPLATE_COLUMNS_WITH_REMOVE[secondaryColumn]
: GRID_TEMPLATE_COLUMNS[secondaryColumn];
return (
<>
<Table>
<TableRow gridTemplateColumns={gridColumns}>
<TableHeader>
<Trans>Name</Trans>
</TableHeader>
<TableHeader align="right">
{secondaryColumn === 'provider' ? (
<Trans>Provider</Trans>
) : (
<Trans>Cost / 1M tokens</Trans>
)}
</TableHeader>
<TableHeader />
{hasRemove && <TableHeader />}
</TableRow>
<TableBody>
{models.map((model) => {
const ModelIcon = getModelIcon(
model.modelFamily,
model.providerName,
);
const displayLabel = getProviderDisplayLabel(model);
const safeId = sanitizeIdForSelector(model.modelId);
const isChecked = model[checkedField] === true;
const isDisabled =
showDisabledState &&
(!model.isAvailable || model.isDeprecated === true);
return (
<div
key={model.modelId}
id={`${anchorPrefix}-${safeId}`}
onMouseEnter={() => setHoveredModelId(model.modelId)}
onMouseLeave={() => setHoveredModelId(null)}
>
<TableRow
gridTemplateColumns={gridColumns}
onClick={
isDisabled
? undefined
: () => onToggle(model.modelId, isChecked)
}
>
<TableCell
color={
isDisabled
? themeCssVariables.font.color.light
: themeCssVariables.font.color.primary
}
>
<StyledModelNameCell>
<ModelIcon
size={theme.icon.size.md}
stroke={theme.icon.stroke.sm}
color={
isDisabled
? theme.font.color.light
: theme.font.color.secondary
}
/>
<StyledModelLabel>{model.label}</StyledModelLabel>
{showDisabledState && model.isDeprecated && (
<StyledDeprecatedSuffix>
· Deprecated
</StyledDeprecatedSuffix>
)}
</StyledModelNameCell>
</TableCell>
<TableCell
align="right"
color={themeCssVariables.font.color.tertiary}
>
{secondaryColumn === 'provider'
? displayLabel
: formatCost(model)}
</TableCell>
<TableCell
align="right"
onClick={(event) => event.stopPropagation()}
>
<Checkbox
checked={isChecked}
disabled={isDisabled}
onChange={() => onToggle(model.modelId, isChecked)}
/>
</TableCell>
{hasRemove && (
<TableCell align="right">
<IconButton
Icon={IconTrash}
accent="danger"
variant="tertiary"
size="small"
onClick={(event) => {
event.stopPropagation();
onRemove(model);
}}
/>
</TableCell>
)}
</TableRow>
</div>
);
})}
</TableBody>
</Table>
{hoveredModel && (
<AppTooltip
anchorSelect={`#${anchorPrefix}-${sanitizeIdForSelector(hoveredModel.modelId)}`}
place="left"
noArrow
offset={8}
delay={TooltipDelay.noDelay}
className={hoverCardTooltipClass}
width="320px"
>
<SettingsAdminAiModelHoverCard
label={hoveredModel.label}
modelFamily={hoveredModel.modelFamily}
providerName={hoveredModel.providerName}
providerLabel={getProviderDisplayLabel(hoveredModel)}
contextWindowTokens={hoveredModel.contextWindowTokens}
maxOutputTokens={hoveredModel.maxOutputTokens}
inputCostPerMillionTokens={hoveredModel.inputCostPerMillionTokens}
outputCostPerMillionTokens={hoveredModel.outputCostPerMillionTokens}
dataResidency={hoveredModel.dataResidency}
/>
</AppTooltip>
)}
</>
);
};
@@ -0,0 +1,7 @@
import { gql } from '@apollo/client';
export const SET_ADMIN_AI_MODELS_ENABLED = gql`
mutation SetAdminAiModelsEnabled($modelIds: [String!]!, $enabled: Boolean!) {
setAdminAiModelsEnabled(modelIds: $modelIds, enabled: $enabled)
}
`;
@@ -0,0 +1,10 @@
import { gql } from '@apollo/client';
export const SET_ADMIN_AI_MODELS_RECOMMENDED = gql`
mutation SetAdminAiModelsRecommended(
$modelIds: [String!]!
$recommended: Boolean!
) {
setAdminAiModelsRecommended(modelIds: $modelIds, recommended: $recommended)
}
`;
@@ -16,7 +16,7 @@ import { UserLookupAdminPanelDocument } from '~/generated-metadata/graphql';
import { currentUserState } from '@/auth/states/currentUserState';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { SettingsAdminTableCard } from '@/settings/admin-panel/components/SettingsAdminTableCard';
import { SettingsTableCard } from '@/settings/components/SettingsTableCard';
import { SettingsAdminVersionContainer } from '@/settings/admin-panel/components/SettingsAdminVersionContainer';
import { SETTINGS_ADMIN_USER_LOOKUP_WORKSPACE_TABS_ID } from '@/settings/admin-panel/constants/SettingsAdminUserLookupWorkspaceTabsId';
import { activeTabIdComponentState } from '@/ui/layout/tab-list/states/activeTabIdComponentState';
@@ -183,7 +183,7 @@ export const SettingsAdminGeneral = () => {
<>
<Section>
<H2Title title={t`User Info`} description={t`About this user`} />
<SettingsAdminTableCard
<SettingsTableCard
items={userInfoItems}
rounded
gridAutoColumns="1fr 4fr"
@@ -1,4 +1,4 @@
import { SettingsAdminTableCard } from '@/settings/admin-panel/components/SettingsAdminTableCard';
import { SettingsTableCard } from '@/settings/components/SettingsTableCard';
import { SettingsAdminVersionDisplay } from '@/settings/admin-panel/components/SettingsAdminVersionDisplay';
import { t } from '@lingui/core/macro';
import { IconCircleDot, IconStatusChange } from 'twenty-ui/display';
@@ -35,10 +35,6 @@ export const SettingsAdminVersionContainer = () => {
];
return (
<SettingsAdminTableCard
rounded
items={versionItems}
gridAutoColumns="3fr 8fr"
/>
<SettingsTableCard rounded items={versionItems} gridAutoColumns="3fr 8fr" />
);
};
@@ -1,7 +1,7 @@
import { currentUserState } from '@/auth/states/currentUserState';
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { canManageFeatureFlagsState } from '@/client-config/states/canManageFeatureFlagsState';
import { SettingsAdminTableCard } from '@/settings/admin-panel/components/SettingsAdminTableCard';
import { SettingsTableCard } from '@/settings/components/SettingsTableCard';
import { useFeatureFlagState } from '@/settings/admin-panel/hooks/useFeatureFlagState';
import { useImpersonationAuth } from '@/settings/admin-panel/hooks/useImpersonationAuth';
import { useImpersonationRedirect } from '@/settings/admin-panel/hooks/useImpersonationRedirect';
@@ -15,13 +15,14 @@ import { TableCell } from '@/ui/layout/table/components/TableCell';
import { TableHeader } from '@/ui/layout/table/components/TableHeader';
import { TableRow } from '@/ui/layout/table/components/TableRow';
import { DEFAULT_WORKSPACE_LOGO } from '@/ui/navigation/navigation-drawer/constants/DefaultWorkspaceLogo';
import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useMutation } from '@apollo/client/react';
import { styled } from '@linaria/react';
import { useLingui } from '@lingui/react/macro';
import { isNonEmptyString } from '@sniptt/guards';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useState } from 'react';
import { getImageAbsoluteURI, isDefined } from 'twenty-shared/utils';
import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState';
import { AvatarOrIcon, Chip } from 'twenty-ui/components';
import {
H2Title,
@@ -35,7 +36,6 @@ import { Button, Toggle } from 'twenty-ui/input';
import { Section } from 'twenty-ui/layout';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { REACT_APP_SERVER_BASE_URL } from '~/config';
import { useMutation } from '@apollo/client/react';
import {
type FeatureFlagKey,
ImpersonateDocument,
@@ -202,7 +202,7 @@ export const SettingsAdminWorkspaceContent = ({
title={t`Workspace Info`}
description={t`About this workspace`}
/>
<SettingsAdminTableCard
<SettingsTableCard
items={workspaceInfoItems}
gridAutoColumns="1fr 4fr"
/>
@@ -1,11 +1,11 @@
import { SettingsAdminTableCard } from '@/settings/admin-panel/components/SettingsAdminTableCard';
import { SettingsTableCard } from '@/settings/components/SettingsTableCard';
import { styled } from '@linaria/react';
import { useLingui } from '@lingui/react/macro';
import { H2Title } from 'twenty-ui/display';
import { Section } from 'twenty-ui/layout';
import { themeCssVariables } from 'twenty-ui/theme-constants';
const StyledSettingsAdminTableCardContainer = styled.div`
const StyledSettingsTableCardContainer = styled.div`
> * {
padding-left: ${themeCssVariables.spacing[2]};
padding-right: ${themeCssVariables.spacing[2]};
@@ -49,15 +49,15 @@ export const SettingsAdminHealthAccountSyncCountersTable = ({
return (
<Section>
<H2Title title={title} description={description} />
<StyledSettingsAdminTableCardContainer>
<SettingsAdminTableCard
<StyledSettingsTableCardContainer>
<SettingsTableCard
items={items}
rounded
gridAutoColumns="1fr 1fr"
labelAlign="left"
valueAlign="right"
/>
</StyledSettingsAdminTableCardContainer>
</StyledSettingsTableCardContainer>
</Section>
);
};
@@ -1,5 +1,5 @@
import { isDefined } from 'twenty-shared/utils';
import { SettingsAdminTableCard } from '@/settings/admin-panel/components/SettingsAdminTableCard';
import { SettingsTableCard } from '@/settings/components/SettingsTableCard';
import { SettingsAdminWorkerMetricsTooltip } from '@/settings/admin-panel/health-status/components/SettingsAdminWorkerMetricsTooltip';
import { useSnackBarOnQueryError } from '@/apollo/hooks/useSnackBarOnQueryError';
import { styled } from '@linaria/react';
@@ -31,7 +31,7 @@ const StyledNoDataMessage = styled.div`
justify-content: center;
`;
const StyledSettingsAdminTableCardContainer = styled.div`
const StyledSettingsTableCardContainer = styled.div`
> * {
padding-left: ${themeCssVariables.spacing[2]};
padding-right: ${themeCssVariables.spacing[2]};
@@ -199,8 +199,8 @@ export const SettingsAdminWorkerMetricsGraph = ({
)}
</StyledGraphContainer>
{isDefined(metricsDetails) && (
<StyledSettingsAdminTableCardContainer>
<SettingsAdminTableCard
<StyledSettingsTableCardContainer>
<SettingsTableCard
rounded
items={Object.entries(metricsDetails)
.filter(([key]) => key !== '__typename')
@@ -217,7 +217,7 @@ export const SettingsAdminWorkerMetricsGraph = ({
labelAlign="left"
valueAlign="right"
/>
</StyledSettingsAdminTableCardContainer>
</StyledSettingsTableCardContainer>
)}
</>
);
@@ -0,0 +1,182 @@
import { styled } from '@linaria/react';
import { t } from '@lingui/core/macro';
import { useContext } from 'react';
import { Chip, ChipAccent, ChipSize, ChipVariant } from 'twenty-ui/components';
import {
IconBolt,
IconBuildingSkyscraper,
IconFlag,
IconTag,
IconUsers,
IconWindow,
type IconComponent,
} from 'twenty-ui/display';
import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants';
import { type AiModelSummary } from '@/settings/ai/types/AiModelSummary';
import { getDataResidencyDisplay } from '@/settings/ai/utils/getDataResidencyDisplay';
import { getModelIcon } from '@/settings/ai/utils/getModelIcon';
import { SettingsTableCard } from '@/settings/components/SettingsTableCard';
import { isDefined } from 'twenty-shared/utils';
import { formatNumber } from '~/utils/format/formatNumber';
const StyledHoverCardWrapper = styled.div`
border-radius: ${themeCssVariables.border.radius.md};
box-shadow: ${themeCssVariables.boxShadow.strong};
overflow: hidden;
width: 320px;
`;
const StyledChipContainer = styled.div`
max-width: 100%;
overflow: hidden;
`;
const StyledValueText = styled.span`
display: inline-block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
`;
const formatCost = (
inputCost?: number | null,
outputCost?: number | null,
): string => {
if (!isDefined(inputCost) && !isDefined(outputCost)) {
return '—';
}
const parts: string[] = [];
if (isDefined(inputCost)) {
parts.push(`$${inputCost} in`);
}
if (isDefined(outputCost)) {
parts.push(`$${outputCost} out`);
}
return parts.join(' / ');
};
type SettingsAiModelHoverCardProps = {
model: AiModelSummary;
};
type HoverCardItem = {
Icon: IconComponent;
label: string;
value: React.ReactNode;
};
export const SettingsAiModelHoverCard = ({
model,
}: SettingsAiModelHoverCardProps) => {
const { theme } = useContext(ThemeContext);
const ModelIcon = getModelIcon(model.modelFamily, model.providerName);
const providerLabel = model.providerLabel ?? model.providerName ?? '—';
const items: HoverCardItem[] = [
{
Icon: IconTag,
label: t`Name`,
value: (
<StyledChipContainer>
<Chip
size={ChipSize.Small}
accent={ChipAccent.TextPrimary}
variant={ChipVariant.Static}
clickable={false}
label={model.label}
leftComponent={
<ModelIcon
size={theme.icon.size.sm}
stroke={theme.icon.stroke.sm}
/>
}
rightComponent={null}
/>
</StyledChipContainer>
),
},
{
Icon: IconBuildingSkyscraper,
label: t`Provider`,
value: <StyledValueText>{providerLabel}</StyledValueText>,
},
...(isDefined(model.inputCostPerMillionTokens) ||
isDefined(model.outputCostPerMillionTokens)
? [
{
Icon: IconUsers,
label: t`Cost per 1M tokens`,
value: (
<StyledValueText>
{formatCost(
model.inputCostPerMillionTokens,
model.outputCostPerMillionTokens,
)}
</StyledValueText>
),
},
]
: []),
...(isDefined(model.contextWindowTokens)
? [
{
Icon: IconWindow,
label: t`Context`,
value: (
<StyledValueText>
{`${formatNumber(model.contextWindowTokens, {
abbreviate: true,
decimals: 1,
})} tokens`}
</StyledValueText>
),
},
]
: []),
...(isDefined(model.maxOutputTokens)
? [
{
Icon: IconBolt,
label: t`Max output`,
value: (
<StyledValueText>
{`${formatNumber(model.maxOutputTokens, {
abbreviate: true,
decimals: 1,
})} tokens`}
</StyledValueText>
),
},
]
: []),
...(isDefined(model.dataResidency)
? [
{
Icon: IconFlag,
label: t`Data residency`,
value: (
<StyledValueText>
{getDataResidencyDisplay(model.dataResidency)}
</StyledValueText>
),
},
]
: []),
];
return (
<StyledHoverCardWrapper>
<SettingsTableCard
rounded
items={items}
gridAutoColumns={`${themeCssVariables.spacing[30]} 1fr`}
/>
</StyledHoverCardWrapper>
);
};
@@ -0,0 +1,227 @@
import { useContext, useState } from 'react';
import { css } from '@linaria/core';
import { styled } from '@linaria/react';
import { Trans } from '@lingui/react/macro';
import { isDefined } from 'twenty-shared/utils';
import { AppTooltip, IconTrash, TooltipDelay } from 'twenty-ui/display';
import { Checkbox, IconButton } from 'twenty-ui/input';
import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants';
import { SettingsAiModelHoverCard } from '@/settings/ai/components/SettingsAiModelHoverCard';
import { type AiModelSummary } from '@/settings/ai/types/AiModelSummary';
import { getModelIcon } from '@/settings/ai/utils/getModelIcon';
import { Table } from '@/ui/layout/table/components/Table';
import { TableBody } from '@/ui/layout/table/components/TableBody';
import { TableCell } from '@/ui/layout/table/components/TableCell';
import { TableHeader } from '@/ui/layout/table/components/TableHeader';
import { TableRow } from '@/ui/layout/table/components/TableRow';
const GRID_COLUMNS = '1fr 120px 40px';
const GRID_COLUMNS_WITHOUT_PROVIDER = '1fr 40px';
const GRID_COLUMNS_WITH_REMOVE = '1fr 120px 40px 32px';
const GRID_COLUMNS_WITH_REMOVE_WITHOUT_PROVIDER = '1fr 40px 32px';
const StyledModelNameCell = styled.div`
align-items: center;
display: flex;
gap: ${themeCssVariables.spacing[2]};
overflow: hidden;
`;
const StyledModelLabel = styled.span`
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
`;
const StyledDeprecatedSuffix = styled.span`
color: ${themeCssVariables.font.color.light};
`;
const hoverCardTooltipClass = css`
border-radius: ${themeCssVariables.border.radius.rounded} !important;
padding: 0 !important;
`;
const sanitizeIdForSelector = (id: string): string =>
id.replace(/[^a-zA-Z0-9-_]/g, '_');
const getProviderDisplayLabel = (model: AiModelSummary): string =>
model.providerLabel ?? model.providerName ?? '';
type SettingsAiModelsTableProps<TModel extends AiModelSummary> = {
models: TModel[];
isChecked: (model: TModel) => boolean;
isDisabled?: (model: TModel) => boolean;
onToggle: (modelId: string, isCurrentlyChecked: boolean) => void;
onToggleAll?: (shouldCheckAll: boolean) => void;
onRemove?: (model: TModel) => void;
showProviderColumn?: boolean;
anchorPrefix?: string;
};
export const SettingsAiModelsTable = <TModel extends AiModelSummary>({
models,
isChecked,
isDisabled,
onToggle,
onToggleAll,
onRemove,
showProviderColumn = true,
anchorPrefix,
}: SettingsAiModelsTableProps<TModel>) => {
const [hoveredModelId, setHoveredModelId] = useState<string | null>(null);
const { theme } = useContext(ThemeContext);
const hoveredModel = models.find((model) => model.modelId === hoveredModelId);
const hasRemove = isDefined(onRemove);
const gridColumns = hasRemove
? showProviderColumn
? GRID_COLUMNS_WITH_REMOVE
: GRID_COLUMNS_WITH_REMOVE_WITHOUT_PROVIDER
: showProviderColumn
? GRID_COLUMNS
: GRID_COLUMNS_WITHOUT_PROVIDER;
const toggleableModels = models.filter(
(model) => !(isDisabled?.(model) ?? false),
);
const checkedCount = toggleableModels.filter((model) =>
isChecked(model),
).length;
const allChecked =
toggleableModels.length > 0 && checkedCount === toggleableModels.length;
const noneChecked = checkedCount === 0;
return (
<>
<Table>
<TableRow gridTemplateColumns={gridColumns}>
<TableHeader>
<Trans>Name</Trans>
</TableHeader>
{showProviderColumn && (
<TableHeader align="right">
<Trans>Provider</Trans>
</TableHeader>
)}
<TableHeader align="right">
{isDefined(onToggleAll) && (
<Checkbox
checked={allChecked}
indeterminate={!allChecked && !noneChecked}
onChange={() => onToggleAll(!allChecked)}
/>
)}
</TableHeader>
{hasRemove && <TableHeader />}
</TableRow>
<TableBody>
{models.map((model) => {
const ModelIcon = getModelIcon(
model.modelFamily,
model.providerName,
);
const safeId = sanitizeIdForSelector(model.modelId);
const checked = isChecked(model);
const disabled = isDisabled?.(model) ?? false;
return (
<TableRow
key={model.modelId}
id={anchorPrefix ? `${anchorPrefix}-${safeId}` : undefined}
gridTemplateColumns={gridColumns}
onMouseEnter={
anchorPrefix
? () => setHoveredModelId(model.modelId)
: undefined
}
onMouseLeave={
anchorPrefix ? () => setHoveredModelId(null) : undefined
}
onClick={
disabled ? undefined : () => onToggle(model.modelId, checked)
}
>
<TableCell
color={
disabled
? themeCssVariables.font.color.light
: themeCssVariables.font.color.primary
}
>
<StyledModelNameCell>
<ModelIcon
size={theme.icon.size.md}
stroke={theme.icon.stroke.sm}
color={
disabled
? theme.font.color.light
: theme.font.color.secondary
}
/>
<StyledModelLabel>{model.label}</StyledModelLabel>
{disabled && model.isDeprecated && (
<StyledDeprecatedSuffix>
· <Trans>Deprecated</Trans>
</StyledDeprecatedSuffix>
)}
</StyledModelNameCell>
</TableCell>
{showProviderColumn && (
<TableCell
align="right"
color={themeCssVariables.font.color.tertiary}
>
{getProviderDisplayLabel(model)}
</TableCell>
)}
<TableCell
align="right"
onClick={(event) => event.stopPropagation()}
>
<Checkbox
checked={checked}
disabled={disabled}
onChange={() => onToggle(model.modelId, checked)}
/>
</TableCell>
{hasRemove && (
<TableCell align="right">
<IconButton
Icon={IconTrash}
accent="danger"
variant="tertiary"
size="small"
onClick={(event) => {
event.stopPropagation();
onRemove(model);
}}
/>
</TableCell>
)}
</TableRow>
);
})}
</TableBody>
</Table>
{anchorPrefix && hoveredModel && (
<AppTooltip
anchorSelect={`#${anchorPrefix}-${sanitizeIdForSelector(hoveredModel.modelId)}`}
place="top-end"
noArrow
offset={8}
delay={TooltipDelay.noDelay}
className={hoverCardTooltipClass}
width="320px"
isOpen={true}
>
<SettingsAiModelHoverCard model={hoveredModel} />
</AppTooltip>
)}
</>
);
};
@@ -0,0 +1,15 @@
import { type ModelFamily } from '~/generated-metadata/graphql';
export type AiModelSummary = {
modelId: string;
label: string;
modelFamily?: ModelFamily | null;
providerName?: string | null;
isDeprecated?: boolean | null;
dataResidency?: string | null;
providerLabel?: string | null;
contextWindowTokens?: number | null;
maxOutputTokens?: number | null;
inputCostPerMillionTokens?: number | null;
outputCostPerMillionTokens?: number | null;
};
@@ -1,8 +1,8 @@
import { type IconComponent } from 'twenty-ui/display';
import { MODEL_ICON_CONFIG } from '@/settings/admin-panel/ai/constants/ModelIconConfig';
import { isModelIconKey } from '@/settings/admin-panel/ai/utils/isModelIconKey';
import { getProviderIcon } from '@/settings/admin-panel/ai/utils/getProviderIcon';
import { isModelIconKey } from '@/settings/admin-panel/ai/utils/isModelIconKey';
import { type ModelFamily } from '~/generated-metadata/graphql';
@@ -1,11 +1,14 @@
import { Table } from '@/ui/layout/table/components/Table';
import { TableBody } from '@/ui/layout/table/components/TableBody';
import { TableCell } from '@/ui/layout/table/components/TableCell';
import { TableRow } from '@/ui/layout/table/components/TableRow';
import { isDefined } from 'twenty-shared/utils';
import { type IconComponent } from 'twenty-ui/display';
import { Card } from 'twenty-ui/layout';
import { styled } from '@linaria/react';
import { useContext } from 'react';
import { isDefined } from 'twenty-shared/utils';
import {
OverflowingTextWithTooltip,
type IconComponent,
} from 'twenty-ui/display';
import { Card } from 'twenty-ui/layout';
import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants';
type TableItem = {
@@ -15,7 +18,14 @@ type TableItem = {
onClick?: () => void;
};
type SettingsAdminTableCardProps = {
const StyledTableBody = styled.div`
display: flex;
flex-direction: column;
gap: ${themeCssVariables.spacing[2]};
padding: ${themeCssVariables.spacing[2]} 0;
`;
type SettingsTableCardProps = {
items: TableItem[];
rounded?: boolean;
gridAutoColumns?: string;
@@ -24,14 +34,14 @@ type SettingsAdminTableCardProps = {
className?: string;
};
export const SettingsAdminTableCard = ({
export const SettingsTableCard = ({
items,
rounded = false,
gridAutoColumns,
labelAlign = 'left',
valueAlign = 'left',
className,
}: SettingsAdminTableCardProps) => {
}: SettingsTableCardProps) => {
const { theme } = useContext(ThemeContext);
return (
<Card
@@ -40,21 +50,22 @@ export const SettingsAdminTableCard = ({
backgroundColor={themeCssVariables.background.secondary}
>
<Table>
<TableBody>
<StyledTableBody>
{items.map((item, index) => (
<TableRow
key={index + item.label}
gridAutoColumns={gridAutoColumns}
height={`${themeCssVariables.spacing[7]}px`}
>
<TableCell
align={labelAlign}
color={themeCssVariables.font.color.tertiary}
height="auto"
gap={themeCssVariables.spacing[2]}
padding={`${themeCssVariables.spacing[2]} ${themeCssVariables.spacing[2]}`}
overflow="hidden"
>
{item.Icon && <item.Icon size={theme.icon.size.md} />}
<span>{item.label}</span>
<OverflowingTextWithTooltip text={item.label} />
</TableCell>
<TableCell
align={valueAlign}
@@ -62,13 +73,12 @@ export const SettingsAdminTableCard = ({
height="auto"
onClick={item.onClick}
clickable={isDefined(item.onClick)}
padding={`${themeCssVariables.spacing[2]} ${themeCssVariables.spacing[2]}`}
>
{item.value}
</TableCell>
</TableRow>
))}
</TableBody>
</StyledTableBody>
</Table>
</Card>
);
@@ -55,10 +55,13 @@ const StyledTableRow = styled.div<{
`;
type TableRowProps = {
id?: string;
isSelected?: boolean;
isExpanded?: boolean;
isClickable?: boolean;
onClick?: () => void;
onMouseEnter?: React.MouseEventHandler<HTMLDivElement>;
onMouseLeave?: React.MouseEventHandler<HTMLDivElement>;
to?: string;
className?: string;
style?: React.CSSProperties;
@@ -71,10 +74,13 @@ type TableRowProps = {
};
export const TableRow = ({
id,
isSelected,
isExpanded,
isClickable,
onClick,
onMouseEnter,
onMouseLeave,
to,
className,
style,
@@ -87,9 +93,12 @@ export const TableRow = ({
hoverBackgroundColor,
}: React.PropsWithChildren<TableRowProps>) => (
<StyledTableRow
id={id}
isSelected={isSelected}
isExpanded={isExpanded}
onClick={onClick}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
gridAutoColumns={gridAutoColumns}
gridTemplateColumns={gridTemplateColumns}
className={className}
@@ -26,14 +26,15 @@ import { RoundedLink, UndecoratedLink } from 'twenty-ui/navigation';
import { useClientConfig } from '@/client-config/hooks/useClientConfig';
import { SettingsSkeletonLoader } from '@/settings/components/SettingsSkeletonLoader';
import { SettingsAdminAiModelsTable } from '@/settings/admin-panel/ai/components/SettingsAdminAiModelsTable';
import { SettingsAiModelsTable } from '@/settings/ai/components/SettingsAiModelsTable';
import { REMOVE_AI_PROVIDER } from '@/settings/admin-panel/ai/graphql/mutations/removeAiProvider';
import { SET_ADMIN_AI_MODELS_ENABLED } from '@/settings/admin-panel/ai/graphql/mutations/setAdminAiModelsEnabled';
import { REMOVE_MODEL_FROM_PROVIDER } from '@/settings/admin-panel/ai/graphql/mutations/removeModelFromProvider';
import { GET_ADMIN_AI_MODELS } from '@/settings/admin-panel/ai/graphql/queries/getAdminAiModels';
import { GET_AI_PROVIDERS } from '@/settings/admin-panel/ai/graphql/queries/getAiProviders';
import { type GetAiProvidersResult } from '@/settings/admin-panel/ai/types/GetAiProvidersResult';
import { getDataResidencyDisplay } from '@/settings/admin-panel/ai/utils/getDataResidencyDisplay';
import { SettingsAdminTableCard } from '@/settings/admin-panel/components/SettingsAdminTableCard';
import { getDataResidencyDisplay } from '@/settings/ai/utils/getDataResidencyDisplay';
import { SettingsTableCard } from '@/settings/components/SettingsTableCard';
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { ConfirmationModal } from '@/ui/layout/modal/components/ConfirmationModal';
@@ -63,13 +64,18 @@ export const SettingsAdminAiProviderDetail = () => {
const { data: providersData, loading: isLoadingProviders } =
useQuery<GetAiProvidersResult>(GET_AI_PROVIDERS);
const { data: modelsData, loading: isLoadingModels } = useQuery<{
const {
data: modelsData,
loading: isLoadingModels,
refetch: refetchModels,
} = useQuery<{
getAdminAiModels: {
models: AdminAiModelConfig[];
};
}>(GET_ADMIN_AI_MODELS);
const [setModelEnabled] = useMutation(SetAdminAiModelEnabledDocument);
const [setModelsEnabled] = useMutation(SET_ADMIN_AI_MODELS_ENABLED);
const [removeAiProvider] = useMutation(REMOVE_AI_PROVIDER);
const [removeModelFromProvider] = useMutation(REMOVE_MODEL_FROM_PROVIDER);
@@ -312,7 +318,7 @@ export const SettingsAdminAiProviderDetail = () => {
/>
{provider && (
<SettingsAdminTableCard
<SettingsTableCard
rounded
items={providerInfoItems}
gridAutoColumns="120px 1fr"
@@ -339,13 +345,43 @@ export const SettingsAdminAiProviderDetail = () => {
)}
{filteredModels.length > 0 && (
<SettingsAdminAiModelsTable
<SettingsAiModelsTable
models={filteredModels}
isChecked={(model) => model.isAdminEnabled}
isDisabled={(model) =>
!model.isAvailable || model.isDeprecated === true
}
onToggle={handleModelToggle}
checkedField="isAdminEnabled"
showProviderColumn={false}
onToggleAll={async (shouldCheckAll) => {
const modelIds = filteredModels
.filter(
(model) =>
model.isAvailable &&
model.isDeprecated !== true &&
model.isAdminEnabled !== shouldCheckAll,
)
.map((model) => model.modelId);
if (modelIds.length === 0) return;
try {
await setModelsEnabled({
variables: {
modelIds,
enabled: shouldCheckAll,
},
});
} catch {
enqueueErrorSnackBar({
message: t`Failed to update model availability`,
});
} finally {
await refetchModels();
await refetchClientConfig();
}
}}
anchorPrefix="provider-model-row"
showDisabledState
secondaryColumn="cost"
onRemove={isCustomProvider ? handleModelRemoveClick : undefined}
/>
)}
@@ -1,5 +1,5 @@
import { useState } from 'react';
import { styled } from '@linaria/react';
import { useState } from 'react';
import {
AUTO_SELECT_FAST_MODEL_ID,
@@ -9,6 +9,9 @@ import {
import { useWorkspaceAiModelAvailability } from '@/ai/hooks/useWorkspaceAiModelAvailability';
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { aiModelsState } from '@/client-config/states/aiModelsState';
import { SettingsAiModelsTable } from '@/settings/ai/components/SettingsAiModelsTable';
import { getDataResidencyDisplay } from '@/settings/ai/utils/getDataResidencyDisplay';
import { getModelIcon } from '@/settings/ai/utils/getModelIcon';
import { SettingsOptionCardContentSelect } from '@/settings/components/SettingsOptions/SettingsOptionCardContentSelect';
import { SettingsOptionCardContentToggle } from '@/settings/components/SettingsOptions/SettingsOptionCardContentToggle';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
@@ -16,18 +19,19 @@ import { Select } from '@/ui/input/components/Select';
import { GenericDropdownContentWidth } from '@/ui/layout/dropdown/constants/GenericDropdownContentWidth';
import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useMutation } from '@apollo/client/react';
import { t } from '@lingui/core/macro';
import { H2Title, IconBolt, IconTwentyStar } from 'twenty-ui/display';
import { H2Title, IconBolt, IconBrain, IconStar } from 'twenty-ui/display';
import { SearchInput } from 'twenty-ui/input';
import { Card, Section } from 'twenty-ui/layout';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { useMutation } from '@apollo/client/react';
import { UpdateWorkspaceDocument } from '~/generated-metadata/graphql';
import { getDataResidencyDisplay } from '@/settings/admin-panel/ai/utils/getDataResidencyDisplay';
import { getModelIcon } from '@/settings/admin-panel/ai/utils/getModelIcon';
const StyledSearchContainer = styled.div`
padding-bottom: ${themeCssVariables.spacing[2]};
const StyledCustomModelsContainer = styled.div`
display: flex;
flex-direction: column;
gap: ${themeCssVariables.spacing[4]};
padding-top: ${themeCssVariables.spacing[4]};
`;
export const SettingsAIModelsTab = () => {
@@ -39,12 +43,10 @@ export const SettingsAIModelsTab = () => {
const [searchQuery, setSearchQuery] = useState('');
const aiModels = useAtomStateValue(aiModelsState);
const {
allModelsWithAvailability,
enabledModels,
useRecommendedModels,
realModels,
} = useWorkspaceAiModelAvailability();
const { enabledModels, useRecommendedModels, realModels } =
useWorkspaceAiModelAvailability();
const enabledModelIdSet = new Set(currentWorkspace?.enabledAiModelIds ?? []);
const currentSmartModel = currentWorkspace?.smartModel;
const currentFastModel = currentWorkspace?.fastModel;
@@ -207,7 +209,7 @@ export const SettingsAIModelsTab = () => {
};
const filteredModels = searchQuery.trim()
? allModelsWithAvailability.filter((model) => {
? realModels.filter((model) => {
const query = searchQuery.toLowerCase();
return (
@@ -216,19 +218,19 @@ export const SettingsAIModelsTab = () => {
(model.sdkPackage?.toLowerCase().includes(query) ?? false)
);
})
: allModelsWithAvailability;
: realModels;
return (
<>
<Section>
<H2Title
title={t`Models`}
description={t`Configure default AI models and availability`}
title={t`Default`}
description={t`Configure your default AI model`}
/>
<Card rounded>
<SettingsOptionCardContentSelect
Icon={IconBolt}
Icon={IconBrain}
title={t`Smart Model`}
description={t`Used for chats, agents, and complex reasoning`}
>
@@ -257,8 +259,17 @@ export const SettingsAIModelsTab = () => {
dropdownWidth={GenericDropdownContentWidth.ExtraLarge}
/>
</SettingsOptionCardContentSelect>
</Card>
</Section>
<Section>
<H2Title
title={t`Available`}
description={t`Models available in the chat model picker`}
/>
<Card rounded>
<SettingsOptionCardContentToggle
Icon={IconTwentyStar}
Icon={IconStar}
title={t`Use best models only`}
description={t`Restrict available models to a curated list`}
checked={useRecommendedModels}
@@ -266,50 +277,52 @@ export const SettingsAIModelsTab = () => {
divider={!useRecommendedModels}
/>
</Card>
</Section>
{!useRecommendedModels && (
<Section>
<H2Title
title={t`Available`}
description={t`Models available in the chat model picker`}
/>
<StyledSearchContainer>
{!useRecommendedModels && (
<StyledCustomModelsContainer>
<SearchInput
placeholder={t`Search a model...`}
value={searchQuery}
onChange={setSearchQuery}
/>
</StyledSearchContainer>
<Card rounded>
{filteredModels.map((model, index) => {
const familyLabel = model.modelFamilyLabel ?? '';
const residency = model.dataResidency
? getDataResidencyDisplay(model.dataResidency)
: undefined;
const description = residency
? `${familyLabel} · ${residency}`
: familyLabel;
<SettingsAiModelsTable
models={filteredModels}
isChecked={(model) => enabledModelIdSet.has(model.modelId)}
onToggle={handleModelToggle}
onToggleAll={async (shouldCheckAll) => {
const previousIds = currentWorkspace?.enabledAiModelIds ?? [];
const visibleModelIds = new Set(
filteredModels.map((m) => m.modelId),
);
return (
<SettingsOptionCardContentToggle
key={model.modelId}
Icon={getModelIcon(model.modelFamily, model.providerName)}
title={model.label}
description={description}
checked={model.isEnabled}
onChange={() =>
handleModelToggle(model.modelId, model.isEnabled)
}
divider={index < filteredModels.length - 1}
/>
);
})}
</Card>
</Section>
)}
const newEnabledIds = shouldCheckAll
? [...new Set([...previousIds, ...visibleModelIds])]
: previousIds.filter((id) => !visibleModelIds.has(id));
try {
setCurrentWorkspace({
...currentWorkspace!,
enabledAiModelIds: newEnabledIds,
});
await updateWorkspace({
variables: { input: { enabledAiModelIds: newEnabledIds } },
});
} catch {
setCurrentWorkspace({
...currentWorkspace!,
enabledAiModelIds: previousIds,
});
enqueueErrorSnackBar({
message: t`Failed to update model availability`,
});
}
}}
anchorPrefix="workspace-model-row"
/>
</StyledCustomModelsContainer>
)}
</Section>
</>
);
};
@@ -1,6 +1,6 @@
import { styled } from '@linaria/react';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { SettingsAdminTableCard } from '@/settings/admin-panel/components/SettingsAdminTableCard';
import { SettingsTableCard } from '@/settings/components/SettingsTableCard';
import { SettingsAdminVersionDisplay } from '@/settings/admin-panel/components/SettingsAdminVersionDisplay';
import { useUpgradeApplication } from '@/marketplace/hooks/useUpgradeApplication';
import { t } from '@lingui/core/macro';
@@ -90,7 +90,7 @@ export const SettingsApplicationVersionContainer = ({
return (
<StyledContainer>
<SettingsAdminTableCard
<SettingsTableCard
rounded
items={versionItems}
gridAutoColumns="3fr 8fr"
@@ -1,4 +1,4 @@
import { SettingsAdminTableCard } from '@/settings/admin-panel/components/SettingsAdminTableCard';
import { SettingsTableCard } from '@/settings/components/SettingsTableCard';
import { SettingsOptionCardContentToggle } from '@/settings/components/SettingsOptions/SettingsOptionCardContentToggle';
import { useQuery } from '@apollo/client/react';
import { styled } from '@linaria/react';
@@ -164,7 +164,7 @@ export const SettingsApplicationRegistrationDistributionTab = ({
title={t`Install Stats`}
description={t`Usage across all workspaces on this server`}
/>
<SettingsAdminTableCard
<SettingsTableCard
rounded
items={statsItems}
gridAutoColumns="3fr 8fr"
@@ -1,4 +1,4 @@
import { SettingsAdminTableCard } from '@/settings/admin-panel/components/SettingsAdminTableCard';
import { SettingsTableCard } from '@/settings/components/SettingsTableCard';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { SettingsTextInput } from '@/ui/input/components/SettingsTextInput';
import { ConfirmationModal } from '@/ui/layout/modal/components/ConfirmationModal';
@@ -387,7 +387,7 @@ export const SettingsApplicationRegistrationGeneralTab = ({
title={t`General`}
description={t`Name and description are managed via your app manifest (CLI)`}
/>
<SettingsAdminTableCard
<SettingsTableCard
rounded
items={generalItems}
gridAutoColumns="3fr 8fr"
@@ -1,4 +1,4 @@
import { SettingsAdminTableCard } from '@/settings/admin-panel/components/SettingsAdminTableCard';
import { SettingsTableCard } from '@/settings/components/SettingsTableCard';
import { ApiKeyInput } from '@/settings/developers/components/ApiKeyInput';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { SettingsTextInput } from '@/ui/input/components/SettingsTextInput';
@@ -208,7 +208,7 @@ export const SettingsApplicationRegistrationOAuthTab = ({
title={t`OAuth Credentials`}
description={t`Credentials and scopes for OAuth authorization flows`}
/>
<SettingsAdminTableCard
<SettingsTableCard
rounded
items={credentialItems}
gridAutoColumns="3fr 8fr"
@@ -228,6 +228,17 @@ export class AdminPanelResolver {
return true;
}
@UseGuards(AdminPanelGuard)
@Mutation(() => Boolean)
async setAdminAiModelsEnabled(
@Args('modelIds', { type: () => [String] }) modelIds: string[],
@Args('enabled', { type: () => Boolean }) enabled: boolean,
): Promise<boolean> {
await this.aiModelRegistryService.setModelsAdminEnabled(modelIds, enabled);
return true;
}
@UseGuards(AdminPanelGuard)
@Mutation(() => Boolean)
async setAdminAiModelRecommended(
@@ -239,6 +250,20 @@ export class AdminPanelResolver {
return true;
}
@UseGuards(AdminPanelGuard)
@Mutation(() => Boolean)
async setAdminAiModelsRecommended(
@Args('modelIds', { type: () => [String] }) modelIds: string[],
@Args('recommended', { type: () => Boolean }) recommended: boolean,
): Promise<boolean> {
await this.aiModelRegistryService.setModelsRecommended(
modelIds,
recommended,
);
return true;
}
@UseGuards(AdminPanelGuard)
@Mutation(() => Boolean)
async setAdminDefaultAiModel(
@@ -56,11 +56,11 @@ export class ClientAIModelConfig {
@Field(() => String, { nullable: true })
sdkPackage: AiSdkPackage | null;
@Field(() => Number)
inputCostPerMillionTokensInCredits: number;
@Field(() => Number, { nullable: true })
inputCostPerMillionTokens?: number;
@Field(() => Number)
outputCostPerMillionTokensInCredits: number;
@Field(() => Number, { nullable: true })
outputCostPerMillionTokens?: number;
@Field(() => NativeModelCapabilities, { nullable: true })
nativeCapabilities?: NativeModelCapabilities;
@@ -74,6 +74,15 @@ export class ClientAIModelConfig {
@Field(() => String, { nullable: true })
providerName?: string;
@Field(() => String, { nullable: true })
providerLabel?: string;
@Field(() => Number, { nullable: true })
contextWindowTokens?: number;
@Field(() => Number, { nullable: true })
maxOutputTokens?: number;
@Field(() => String, { nullable: true })
dataResidency?: string;
}
@@ -38,6 +38,9 @@ describe('ClientConfigService', () => {
getAdminFilteredModels: jest.fn().mockReturnValue([]),
getRecommendedModelIds: jest.fn().mockReturnValue(new Set()),
getModelConfig: jest.fn().mockReturnValue(undefined),
getResolvedProvidersForAdmin: jest.fn().mockReturnValue({}),
getDefaultSpeedModel: jest.fn().mockReturnValue(undefined),
getDefaultPerformanceModel: jest.fn().mockReturnValue(undefined),
},
},
{
@@ -21,7 +21,6 @@ import {
import { DomainServerConfigService } from 'src/engine/core-modules/domain/domain-server-config/services/domain-server-config.service';
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 {
AUTO_SELECT_FAST_MODEL_ID,
AUTO_SELECT_SMART_MODEL_ID,
@@ -69,6 +68,13 @@ export class ClientConfigService {
this.aiModelRegistryService.getAdminFilteredModels();
const recommendedModelIds =
this.aiModelRegistryService.getRecommendedModelIds();
const resolvedProviders =
this.aiModelRegistryService.getResolvedProvidersForAdmin();
const getProviderLabel = (providerName?: string | null) =>
providerName
? (resolvedProviders[providerName]?.label ?? providerName)
: undefined;
const aiModels: ClientAIModelConfig[] = availableModels.map(
(registeredModel) => {
@@ -77,6 +83,7 @@ export class ClientConfigService {
);
const modelFamily = modelConfig?.modelFamily;
const providerName = registeredModel.providerName;
return {
modelId: registeredModel.modelId,
@@ -86,20 +93,15 @@ export class ClientConfigService {
? MODEL_FAMILY_LABELS[modelFamily]
: undefined,
sdkPackage: registeredModel.sdkPackage,
providerName: registeredModel.providerName,
providerName,
providerLabel: getProviderLabel(providerName),
nativeCapabilities: this.deriveNativeCapabilities(
registeredModel.sdkPackage,
),
inputCostPerMillionTokensInCredits: modelConfig
? convertDollarsToBillingCredits(
modelConfig.inputCostPerMillionTokens,
)
: 0,
outputCostPerMillionTokensInCredits: modelConfig
? convertDollarsToBillingCredits(
modelConfig.outputCostPerMillionTokens,
)
: 0,
inputCostPerMillionTokens: modelConfig?.inputCostPerMillionTokens,
outputCostPerMillionTokens: modelConfig?.outputCostPerMillionTokens,
contextWindowTokens: modelConfig?.contextWindowTokens,
maxOutputTokens: modelConfig?.maxOutputTokens,
isDeprecated: modelConfig?.isDeprecated,
isRecommended: recommendedModelIds.has(registeredModel.modelId),
dataResidency: modelConfig?.dataResidency,
@@ -129,9 +131,17 @@ export class ClientConfigService {
'Default',
modelFamily: defaultPerformanceModelConfig?.modelFamily,
providerName: defaultPerformanceModel?.providerName,
providerLabel: getProviderLabel(
defaultPerformanceModel?.providerName,
),
sdkPackage: defaultPerformanceModel?.sdkPackage ?? null,
inputCostPerMillionTokensInCredits: 0,
outputCostPerMillionTokensInCredits: 0,
inputCostPerMillionTokens:
defaultPerformanceModelConfig?.inputCostPerMillionTokens,
outputCostPerMillionTokens:
defaultPerformanceModelConfig?.outputCostPerMillionTokens,
contextWindowTokens:
defaultPerformanceModelConfig?.contextWindowTokens,
maxOutputTokens: defaultPerformanceModelConfig?.maxOutputTokens,
},
{
modelId: AUTO_SELECT_FAST_MODEL_ID,
@@ -141,9 +151,14 @@ export class ClientConfigService {
'Default',
modelFamily: defaultSpeedModelConfig?.modelFamily,
providerName: defaultSpeedModel?.providerName,
providerLabel: getProviderLabel(defaultSpeedModel?.providerName),
sdkPackage: defaultSpeedModel?.sdkPackage ?? null,
inputCostPerMillionTokensInCredits: 0,
outputCostPerMillionTokensInCredits: 0,
inputCostPerMillionTokens:
defaultSpeedModelConfig?.inputCostPerMillionTokens,
outputCostPerMillionTokens:
defaultSpeedModelConfig?.outputCostPerMillionTokens,
contextWindowTokens: defaultSpeedModelConfig?.contextWindowTokens,
maxOutputTokens: defaultSpeedModelConfig?.maxOutputTokens,
},
);
}
@@ -29,6 +29,24 @@ export class AiModelPreferencesService {
await this.togglePreferenceList(modelId, 'recommendedModels', recommended);
}
async setModelsAdminEnabled(
modelIds: string[],
enabled: boolean,
): Promise<void> {
await this.togglePreferenceListBulk(modelIds, 'disabledModels', !enabled);
}
async setModelsRecommended(
modelIds: string[],
recommended: boolean,
): Promise<void> {
await this.togglePreferenceListBulk(
modelIds,
'recommendedModels',
recommended,
);
}
async setDefaultModel(role: AiModelRole, modelId: string): Promise<void> {
const prefs = { ...this.getPreferences() };
const key =
@@ -46,16 +64,26 @@ export class AiModelPreferencesService {
modelId: string,
key: 'disabledModels' | 'recommendedModels',
add: boolean,
): Promise<void> {
await this.togglePreferenceListBulk([modelId], key, add);
}
private async togglePreferenceListBulk(
modelIds: string[],
key: 'disabledModels' | 'recommendedModels',
add: boolean,
): Promise<void> {
const prefs = { ...this.getPreferences() };
const current = prefs[key] ?? [];
const idSet = new Set(modelIds);
if (add) {
if (!current.includes(modelId)) {
prefs[key] = [...current, modelId];
}
const existing = new Set(current);
const toAdd = modelIds.filter((id) => !existing.has(id));
prefs[key] = [...current, ...toAdd];
} else {
prefs[key] = current.filter((id) => id !== modelId);
prefs[key] = current.filter((id) => !idSet.has(id));
}
await this.twentyConfigService.set('AI_MODEL_PREFERENCES', prefs);
@@ -361,6 +361,22 @@ export class AiModelRegistryService {
await this.preferencesService.setModelRecommended(modelId, recommended);
}
async setModelsAdminEnabled(
modelIds: string[],
enabled: boolean,
): Promise<void> {
modelIds.forEach((id) => this.validateModelInRegistry(id));
await this.preferencesService.setModelsAdminEnabled(modelIds, enabled);
}
async setModelsRecommended(
modelIds: string[],
recommended: boolean,
): Promise<void> {
modelIds.forEach((id) => this.validateModelInRegistry(id));
await this.preferencesService.setModelsRecommended(modelIds, recommended);
}
async setDefaultModel(role: AiModelRole, modelId: string): Promise<void> {
this.validateModelInRegistry(modelId);
await this.preferencesService.setDefaultModel(role, modelId);
@@ -10,6 +10,7 @@ export {
IconApi,
IconApps,
IconAppWindow,
IconWindow,
IconArchive,
IconArchiveOff,
IconArrowBackUp,
@@ -374,6 +375,7 @@ export {
IconSquareRoundedX,
IconSquareX,
IconStack2,
IconStar,
IconStatusChange,
IconStepInto,
IconStrikethrough,
+2
View File
@@ -88,6 +88,7 @@ export {
IconApi,
IconApps,
IconAppWindow,
IconWindow,
IconArchive,
IconArchiveOff,
IconArrowBackUp,
@@ -452,6 +453,7 @@ export {
IconSquareRoundedX,
IconSquareX,
IconStack2,
IconStar,
IconStatusChange,
IconStepInto,
IconStrikethrough,