Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
106 changes: 104 additions & 2 deletions packages/twenty-front/src/generated-metadata/graphql.ts

Large diffs are not rendered by default.

26 changes: 11 additions & 15 deletions packages/twenty-front/src/modules/ai/hooks/useAiModelOptions.ts
Original file line number Diff line number Diff line change
@@ -1,39 +1,35 @@
import { aiModelsState } from '@/client-config/states/aiModelsState';
import { type SelectOption } from 'twenty-ui/input';

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 { useRecoilValueV2 } from '@/ui/utilities/state/jotai/hooks/useRecoilValueV2';
import { MODEL_FAMILY_CONFIG } from '~/pages/settings/ai/constants/SettingsAiModelProviders';
import { getModelProviderLabel } from '~/pages/settings/ai/utils/getModelProviderLabel';

export const useAiModelOptions = (
includeDeprecated = false,
): SelectOption<string>[] => {
const aiModels = useRecoilValueV2(aiModelsState);
const { isModelEnabled } = useWorkspaceAiModelAvailability();

return aiModels
.filter((model) => includeDeprecated || !model.deprecated)
.filter(
(model) =>
(includeDeprecated || !model.deprecated) &&
isModelEnabled(model.modelId, model),
)
.map((model) => ({
value: model.modelId,
label:
model.modelId === DEFAULT_FAST_MODEL ||
model.modelId === DEFAULT_SMART_MODEL
? model.label
: `${model.label} (${getModelFamilyLabel(model.modelFamily) ?? model.inferenceProvider})`,
: `${model.label} (${getModelProviderLabel(model.modelFamily) || model.inferenceProvider})`,
}))
.sort((a, b) => a.label.localeCompare(b.label));
};

const getModelFamilyLabel = (
modelFamily: string | null | undefined,
): string | undefined => {
if (!modelFamily) {
return undefined;
}

return MODEL_FAMILY_CONFIG[modelFamily]?.label || modelFamily;
};

export const useAiModelLabel = (
modelId: string | undefined,
includeProvider = true,
Expand All @@ -58,5 +54,5 @@ export const useAiModelLabel = (
return model.label;
}

return `${model.label} (${getModelFamilyLabel(model.modelFamily) ?? model.inferenceProvider})`;
return `${model.label} (${getModelProviderLabel(model.modelFamily) || model.inferenceProvider})`;
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { DEFAULT_FAST_MODEL } from '@/ai/constants/DefaultFastModel';
import { DEFAULT_SMART_MODEL } from '@/ai/constants/DefaultSmartModel';
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { aiModelsState } from '@/client-config/states/aiModelsState';
import { useRecoilValueV2 } from '@/ui/utilities/state/jotai/hooks/useRecoilValueV2';
import { useRecoilValue } from 'recoil';
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 = useRecoilValueV2(aiModelsState);
const currentWorkspace = useRecoilValue(currentWorkspaceState);

const useRecommendedModels = currentWorkspace?.useRecommendedModels ?? true;
const autoEnableNewAiModels = currentWorkspace?.autoEnableNewAiModels ?? true;
const disabledAiModelIds = currentWorkspace?.disabledAiModelIds ?? [];
const enabledAiModelIds = currentWorkspace?.enabledAiModelIds ?? [];

const isModelEnabled = (
modelId: string,
model?: ClientAiModelConfig,
): boolean => {
if (isVirtualModel(modelId)) {
return true;
}

if (useRecommendedModels) {
return model?.isRecommended === true;
}

return autoEnableNewAiModels
? !disabledAiModelIds.includes(modelId)
: enabledAiModelIds.includes(modelId);
};

const realModels = aiModels.filter(
(model) => !isVirtualModel(model.modelId) && !model.deprecated,
);

const enabledModels = realModels.filter((model) =>
isModelEnabled(model.modelId, model),
);

const allModelsWithAvailability = realModels.map((model) => ({
...model,
isEnabled: isModelEnabled(model.modelId, model),
}));

return {
isModelEnabled,
enabledModels,
realModels,
allModelsWithAvailability,
useRecommendedModels,
autoEnableNewAiModels,
disabledAiModelIds,
enabledAiModelIds,
};
};

export type AiModelWithAvailability = ClientAiModelConfig & {
isEnabled: boolean;
};
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,10 @@ const mockWorkspace = {
fastModel: DEFAULT_FAST_MODEL,
smartModel: DEFAULT_SMART_MODEL,
routerModel: 'auto',
autoEnableNewAiModels: true,
disabledAiModelIds: [],
enabledAiModelIds: [],
useRecommendedModels: true,
workspaceCustomApplication: CUSTOM_WORKSPACE_APPLICATION_MOCK,
workspaceCustomApplicationId: CUSTOM_WORKSPACE_APPLICATION_MOCK.id,
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,10 @@ export type CurrentWorkspace = Pick<
| 'smartModel'
| 'aiAdditionalInstructions'
| 'editableProfileFields'
| 'autoEnableNewAiModels'
| 'disabledAiModelIds'
| 'enabledAiModelIds'
| 'useRecommendedModels'
> & {
defaultRole?: Omit<Role, 'workspaceMembers' | 'agents' | 'apiKeys'> | null;
workspaceCustomApplication: Pick<Application, 'id'> | null;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,10 @@ describe('useColumnDefinitionsFromObjectMetadata', () => {
eventLogRetentionDays: 365 * 3,
fastModel: DEFAULT_FAST_MODEL,
smartModel: DEFAULT_SMART_MODEL,
autoEnableNewAiModels: true,
disabledAiModelIds: [],
enabledAiModelIds: [],
useRecommendedModels: true,
});

const companyObjectMetadata = generatedMockObjectMetadataItems.find(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,233 @@
import { useState } from 'react';
import styled from '@emotion/styled';

import { useClientConfig } from '@/client-config/hooks/useClientConfig';
import { GET_ADMIN_AI_MODELS } from '@/settings/admin-panel/ai/graphql/queries/getAdminAiModels';
import { SettingsOptionCardContentToggle } from '@/settings/components/SettingsOptions/SettingsOptionCardContentToggle';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { SettingsTextInput } from '@/ui/input/components/SettingsTextInput';
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { t } from '@lingui/core/macro';
import {
H2Title,
IconArchive,
IconFilter,
IconPlug,
IconRobot,
IconSearch,
} from 'twenty-ui/display';
import { Button } from 'twenty-ui/input';
import { Card, Section } from 'twenty-ui/layout';
import { MenuItemToggle } from 'twenty-ui/navigation';
import {
useCreateDatabaseConfigVariableMutation,
useGetAdminAiModelsQuery,
useSetAdminAiModelEnabledMutation,
} from '~/generated-metadata/graphql';
import { getModelIcon } from '~/pages/settings/ai/utils/getModelIcon';
import { getModelProviderLabel } from '~/pages/settings/ai/utils/getModelProviderLabel';

const StyledSearchAndFilterContainer = styled.div`
display: flex;
gap: ${({ theme }) => theme.spacing(2)};
margin-bottom: ${({ theme }) => theme.spacing(2)};
width: 100%;
`;

const StyledSearchInput = styled(SettingsTextInput)`
flex: 1;
`;

export const SettingsAdminAI = () => {
const { enqueueErrorSnackBar } = useSnackBar();
const [searchQuery, setSearchQuery] = useState('');
const [showUnconfigured, setShowUnconfigured] = useState(false);
const [showDeprecated, setShowDeprecated] = useState(false);
const { refetch: refetchClientConfig } = useClientConfig();

const { data } = useGetAdminAiModelsQuery();
const [createConfigVariable] = useCreateDatabaseConfigVariableMutation();
const [setModelEnabled] = useSetAdminAiModelEnabledMutation();

const autoEnableNewModels =
data?.getAdminAiModels?.autoEnableNewModels ?? true;

const models = data?.getAdminAiModels?.models ?? [];

const handleAutoEnableToggle = async (checked: boolean) => {
try {
await createConfigVariable({
variables: {
key: 'AI_AUTO_ENABLE_NEW_MODELS',
value: checked,
},
refetchQueries: [{ query: GET_ADMIN_AI_MODELS }],
});

await refetchClientConfig();
} catch {
enqueueErrorSnackBar({
message: t`Failed to update auto-enable setting`,
});
}
};

const handleModelToggle = async (
modelId: string,
isCurrentlyEnabled: boolean,
) => {
try {
await setModelEnabled({
variables: {
modelId,
enabled: !isCurrentlyEnabled,
},
refetchQueries: [{ query: GET_ADMIN_AI_MODELS }],
});

await refetchClientConfig();
} catch {
enqueueErrorSnackBar({
message: t`Failed to update model availability`,
});
}
};

let filteredModels = models;

if (!showUnconfigured) {
filteredModels = filteredModels.filter((model) => model.isAvailable);
}

if (!showDeprecated) {
filteredModels = filteredModels.filter((model) => !model.deprecated);
}

if (searchQuery.trim().length > 0) {
const query = searchQuery.toLowerCase();

filteredModels = filteredModels.filter(
(model) =>
model.label.toLowerCase().includes(query) ||
(model.modelFamily?.toLowerCase().includes(query) ?? false) ||
model.inferenceProvider.toLowerCase().includes(query),
);
}

const getModelDescription = (
modelFamily: string | null | undefined,
isAvailable: boolean,
isDeprecated: boolean | null | undefined,
) => {
const providerLabel = getModelProviderLabel(modelFamily);

if (isDeprecated === true) {
return providerLabel ? t`${providerLabel} — Deprecated` : t`Deprecated`;
}

if (!isAvailable) {
return providerLabel
? t`${providerLabel} — API key not configured`
: t`API key not configured`;
}

return providerLabel;
};

return (
<>
<Section>
<H2Title
title={t`Admin Model Controls`}
description={t`Server-wide AI model availability settings`}
/>

<Card rounded>
<SettingsOptionCardContentToggle
Icon={IconRobot}
title={t`Automatically enable new models`}
description={t`When enabled, newly added models are available to all workspaces by default`}
checked={autoEnableNewModels}
onChange={handleAutoEnableToggle}
/>
</Card>
</Section>

<Section>
<H2Title
title={t`All Models`}
description={t`Toggle model availability across all workspaces`}
/>

<StyledSearchAndFilterContainer>
<StyledSearchInput
instanceId="admin-model-search"
LeftIcon={IconSearch}
placeholder={t`Search a model...`}
value={searchQuery}
onChange={setSearchQuery}
/>
<Dropdown
dropdownId="admin-ai-models-filter-dropdown"
dropdownPlacement="bottom-end"
dropdownOffset={{ x: 0, y: 8 }}
clickableComponent={
<Button
Icon={IconFilter}
size="medium"
variant="secondary"
accent="default"
ariaLabel={t`Filter`}
/>
}
dropdownComponents={
<DropdownContent>
<DropdownMenuItemsContainer>
<MenuItemToggle
LeftIcon={IconPlug}
onToggleChange={() =>
setShowUnconfigured(!showUnconfigured)
}
toggled={showUnconfigured}
text={t`Unconfigured models`}
toggleSize="small"
/>
<MenuItemToggle
LeftIcon={IconArchive}
onToggleChange={() => setShowDeprecated(!showDeprecated)}
toggled={showDeprecated}
text={t`Deprecated models`}
toggleSize="small"
/>
</DropdownMenuItemsContainer>
</DropdownContent>
}
/>
</StyledSearchAndFilterContainer>

<Card rounded>
{filteredModels.map((model, index) => (
<SettingsOptionCardContentToggle
key={model.modelId}
Icon={getModelIcon(model.modelFamily)}
title={model.label}
description={getModelDescription(
model.modelFamily,
model.isAvailable,
model.deprecated,
)}
checked={model.isAdminEnabled}
onChange={() =>
handleModelToggle(model.modelId, model.isAdminEnabled)
}
disabled={!model.isAvailable || model.deprecated === true}
divider={index < filteredModels.length - 1}
/>
))}
</Card>
</Section>
</>
);
};
Loading
Loading