feat: replace hardcoded AI model constants with JSON seed catalog (#18818)
## Summary - Replaces per-provider TypeScript constant files (`openai-models.const.ts`, `anthropic-models.const.ts`, etc.) with a single `ai-providers.json` catalog as the source of truth - Adds runtime model discovery via AI SDK for self-hosted providers, with `models.dev` enrichment for pricing/capabilities - Introduces composite model IDs (`provider/modelId`) for canonical, conflict-free identification - Simplifies provider configuration: API keys are injected from environment variables (e.g., `OPENAI_API_KEY`) - Adds admin panel UI for provider management (add/remove/test), model discovery, recommended model configuration, and default fast/smart model selection per workspace - Removes deprecated config variables (`AI_DISABLED_MODEL_IDS`, `AUTO_ENABLE_NEW_AI_MODELS`, etc.) - Adds database migration for composite model ID format ## Test plan - [ ] Server typecheck passes - [ ] Frontend typecheck passes - [ ] Server unit tests pass - [ ] Frontend unit tests pass - [ ] CI pipeline green - [ ] Admin panel AI tab loads correctly - [ ] Provider discovery works for configured providers - [ ] Model recommendation toggles persist - [ ] Default fast/smart model selection works Made with [Cursor](https://cursor.com)
This commit is contained in:
+45
@@ -0,0 +1,45 @@
|
||||
import { styled } from '@linaria/react';
|
||||
import { type IconComponentProps } from 'twenty-ui/display';
|
||||
|
||||
type ModelsDevProviderLogoProps = {
|
||||
logoUrl: string;
|
||||
} & Pick<IconComponentProps, 'className' | 'size' | 'style'>;
|
||||
|
||||
const resolvePixelSize = (
|
||||
size: IconComponentProps['size'] | undefined,
|
||||
): number => {
|
||||
if (typeof size === 'number') {
|
||||
return size;
|
||||
}
|
||||
if (typeof size === 'string') {
|
||||
const parsed = parseInt(size, 10);
|
||||
|
||||
return Number.isNaN(parsed) ? 16 : parsed;
|
||||
}
|
||||
|
||||
return 16;
|
||||
};
|
||||
|
||||
const StyledLogo = styled.img`
|
||||
object-fit: contain;
|
||||
`;
|
||||
|
||||
export const ModelsDevProviderLogo = ({
|
||||
logoUrl,
|
||||
size,
|
||||
className,
|
||||
style,
|
||||
}: ModelsDevProviderLogoProps) => {
|
||||
const pixelSize = resolvePixelSize(size);
|
||||
|
||||
return (
|
||||
<StyledLogo
|
||||
alt=""
|
||||
className={className}
|
||||
height={pixelSize}
|
||||
src={logoUrl}
|
||||
style={style}
|
||||
width={pixelSize}
|
||||
/>
|
||||
);
|
||||
};
|
||||
+177
-160
@@ -1,206 +1,223 @@
|
||||
import { useState } from 'react';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { useMutation, useQuery } from '@apollo/client/react';
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { H2Title, IconBolt, IconLock, IconRobot } from 'twenty-ui/display';
|
||||
import { Card, Section } from 'twenty-ui/layout';
|
||||
import { Tag } from 'twenty-ui/components';
|
||||
|
||||
import { useClientConfig } from '@/client-config/hooks/useClientConfig';
|
||||
import { billingState } from '@/client-config/states/billingState';
|
||||
import { AI_PROVIDER_SOURCE } from '@/settings/admin-panel/ai/constants/AiProviderSource';
|
||||
import { SettingsAdminTabSkeletonLoader } from '@/settings/admin-panel/components/SettingsAdminTabSkeletonLoader';
|
||||
import { SettingsOptionCardContentSelect } from '@/settings/components/SettingsOptions/SettingsOptionCardContentSelect';
|
||||
import { Select } from '@/ui/input/components/Select';
|
||||
import { SettingsAdminAiModelsTable } from '@/settings/admin-panel/ai/components/SettingsAdminAiModelsTable';
|
||||
import { SettingsAdminAiProviderListCard } from '@/settings/admin-panel/ai/components/SettingsAdminAiProviderListCard';
|
||||
import { GET_ADMIN_AI_MODELS } from '@/settings/admin-panel/ai/graphql/queries/getAdminAiModels';
|
||||
import { SettingsOptionCardContentToggle } from '@/settings/components/SettingsOptions/SettingsOptionCardContentToggle';
|
||||
import { GET_AI_PROVIDERS } from '@/settings/admin-panel/ai/graphql/queries/getAiProviders';
|
||||
import { type GetAiProvidersResult } from '@/settings/admin-panel/ai/types/GetAiProvidersResult';
|
||||
import { parseProviderItems } from '@/settings/admin-panel/ai/utils/parseProviderItems';
|
||||
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
|
||||
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, IconPlug, IconRobot } from 'twenty-ui/display';
|
||||
import { SearchInput } from 'twenty-ui/input';
|
||||
import { Card, Section } from 'twenty-ui/layout';
|
||||
import { MenuItemToggle } from 'twenty-ui/navigation';
|
||||
import { useMutation, useQuery } from '@apollo/client/react';
|
||||
import { GenericDropdownContentWidth } from '@/ui/layout/dropdown/constants/GenericDropdownContentWidth';
|
||||
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
|
||||
import { SET_ADMIN_AI_MODEL_RECOMMENDED } from '@/settings/admin-panel/ai/graphql/mutations/setAdminAiModelRecommended';
|
||||
import { SET_ADMIN_DEFAULT_AI_MODEL } from '@/settings/admin-panel/ai/graphql/mutations/setAdminDefaultAiModel';
|
||||
import { getModelIcon } from '@/settings/admin-panel/ai/utils/getModelIcon';
|
||||
import {
|
||||
CreateDatabaseConfigVariableDocument,
|
||||
GetAdminAiModelsDocument,
|
||||
SetAdminAiModelEnabledDocument,
|
||||
AiModelRole,
|
||||
type AdminAiModelConfig,
|
||||
} from '~/generated-metadata/graphql';
|
||||
import { getModelIcon } from '~/pages/settings/ai/utils/getModelIcon';
|
||||
import { getModelProviderLabel } from '~/pages/settings/ai/utils/getModelProviderLabel';
|
||||
|
||||
export const SettingsAdminAI = () => {
|
||||
const { enqueueErrorSnackBar } = useSnackBar();
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [showUnconfigured, setShowUnconfigured] = useState(false);
|
||||
const [showDeprecated, setShowDeprecated] = useState(false);
|
||||
const billing = useAtomStateValue(billingState);
|
||||
const isBillingEnabled = billing?.isBillingEnabled ?? false;
|
||||
const { refetch: refetchClientConfig } = useClientConfig();
|
||||
|
||||
const { data } = useQuery(GetAdminAiModelsDocument);
|
||||
const [createConfigVariable] = useMutation(
|
||||
CreateDatabaseConfigVariableDocument,
|
||||
);
|
||||
const [setModelEnabled] = useMutation(SetAdminAiModelEnabledDocument);
|
||||
const { data, loading: isLoadingModels } = useQuery<{
|
||||
getAdminAiModels: {
|
||||
defaultSmartModelId?: string | null;
|
||||
defaultFastModelId?: string | null;
|
||||
models: AdminAiModelConfig[];
|
||||
};
|
||||
}>(GET_ADMIN_AI_MODELS);
|
||||
|
||||
const autoEnableNewModels =
|
||||
data?.getAdminAiModels?.autoEnableNewModels ?? true;
|
||||
const [setModelRecommended] = useMutation(SET_ADMIN_AI_MODEL_RECOMMENDED);
|
||||
const [setDefaultModel] = useMutation(SET_ADMIN_DEFAULT_AI_MODEL);
|
||||
|
||||
const { data: providersData, loading: isLoadingProviders } =
|
||||
useQuery<GetAiProvidersResult>(GET_AI_PROVIDERS, {
|
||||
skip: isBillingEnabled,
|
||||
});
|
||||
|
||||
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 }],
|
||||
});
|
||||
const providerItems = useMemo(
|
||||
() => parseProviderItems(providersData?.getAiProviders ?? {}),
|
||||
[providersData],
|
||||
);
|
||||
|
||||
await refetchClientConfig();
|
||||
} catch {
|
||||
enqueueErrorSnackBar({
|
||||
message: t`Failed to update auto-enable setting`,
|
||||
});
|
||||
}
|
||||
};
|
||||
const catalogProviders = useMemo(
|
||||
() =>
|
||||
providerItems
|
||||
.filter((provider) => provider.source === AI_PROVIDER_SOURCE.CATALOG)
|
||||
.sort((a, b) => (a.label ?? a.id).localeCompare(b.label ?? b.id)),
|
||||
[providerItems],
|
||||
);
|
||||
|
||||
const handleModelToggle = async (
|
||||
const customProviders = providerItems.filter(
|
||||
(provider) => provider.source === AI_PROVIDER_SOURCE.CUSTOM,
|
||||
);
|
||||
|
||||
if (isLoadingProviders || isLoadingModels) {
|
||||
return <SettingsAdminTabSkeletonLoader />;
|
||||
}
|
||||
|
||||
const handleRecommendedToggle = async (
|
||||
modelId: string,
|
||||
isCurrentlyEnabled: boolean,
|
||||
isCurrentlyRecommended: boolean,
|
||||
) => {
|
||||
try {
|
||||
await setModelEnabled({
|
||||
variables: {
|
||||
modelId,
|
||||
enabled: !isCurrentlyEnabled,
|
||||
},
|
||||
await setModelRecommended({
|
||||
variables: { modelId, recommended: !isCurrentlyRecommended },
|
||||
refetchQueries: [{ query: GET_ADMIN_AI_MODELS }],
|
||||
});
|
||||
|
||||
await refetchClientConfig();
|
||||
} catch {
|
||||
enqueueErrorSnackBar({
|
||||
message: t`Failed to update model availability`,
|
||||
message: t`Failed to update model recommendation`,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
let filteredModels = models;
|
||||
const defaultSmartModelId = data?.getAdminAiModels?.defaultSmartModelId;
|
||||
const defaultFastModelId = data?.getAdminAiModels?.defaultFastModelId;
|
||||
|
||||
if (!showUnconfigured) {
|
||||
filteredModels = filteredModels.filter((model) => model.isAvailable);
|
||||
}
|
||||
const enabledModels = models.filter(
|
||||
(model) => model.isAvailable && model.isAdminEnabled && !model.isDeprecated,
|
||||
);
|
||||
|
||||
if (!showDeprecated) {
|
||||
filteredModels = filteredModels.filter((model) => !model.deprecated);
|
||||
}
|
||||
const availableModelOptions = enabledModels.map((model) => ({
|
||||
value: model.modelId,
|
||||
label: model.label,
|
||||
Icon: getModelIcon(model.modelFamily, model.providerName),
|
||||
}));
|
||||
|
||||
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 handleDefaultModelChange = async (
|
||||
role: AiModelRole,
|
||||
modelId: string,
|
||||
) => {
|
||||
const providerLabel = getModelProviderLabel(modelFamily);
|
||||
|
||||
if (isDeprecated === true) {
|
||||
return providerLabel ? t`${providerLabel} — Deprecated` : t`Deprecated`;
|
||||
try {
|
||||
await setDefaultModel({
|
||||
variables: { role, modelId },
|
||||
refetchQueries: [{ query: GET_ADMIN_AI_MODELS }],
|
||||
});
|
||||
await refetchClientConfig();
|
||||
} catch {
|
||||
enqueueErrorSnackBar({
|
||||
message: t`Failed to update default model`,
|
||||
});
|
||||
}
|
||||
|
||||
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`}
|
||||
/>
|
||||
{!isBillingEnabled && (
|
||||
<>
|
||||
<Section>
|
||||
<H2Title
|
||||
title={t`Providers`}
|
||||
description={t`Built-in providers activated by API key. Click to manage models.`}
|
||||
/>
|
||||
|
||||
<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}
|
||||
<SettingsAdminAiProviderListCard
|
||||
providers={catalogProviders}
|
||||
showAddButton={false}
|
||||
/>
|
||||
</Section>
|
||||
|
||||
<Section>
|
||||
<H2Title
|
||||
title={t`Custom Providers`}
|
||||
description={t`Add custom endpoints, private gateways, or additional regions.`}
|
||||
adornment={
|
||||
<Tag
|
||||
text={t`Enterprise`}
|
||||
color="transparent"
|
||||
Icon={IconLock}
|
||||
variant="border"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<SettingsAdminAiProviderListCard
|
||||
providers={customProviders}
|
||||
showAddButton
|
||||
/>
|
||||
</Section>
|
||||
</>
|
||||
)}
|
||||
|
||||
{availableModelOptions.length > 0 && (
|
||||
<Section>
|
||||
<H2Title
|
||||
title={t`Default Models`}
|
||||
description={t`Configure the default AI models for all workspaces`}
|
||||
/>
|
||||
</Card>
|
||||
</Section>
|
||||
|
||||
<Section>
|
||||
<H2Title
|
||||
title={t`All Models`}
|
||||
description={t`Toggle model availability across all workspaces`}
|
||||
/>
|
||||
<Card rounded>
|
||||
<SettingsOptionCardContentSelect
|
||||
Icon={IconRobot}
|
||||
title={t`Smart Model`}
|
||||
description={t`Default model for chats and complex reasoning`}
|
||||
>
|
||||
<Select
|
||||
dropdownId="admin-smart-model-select"
|
||||
value={defaultSmartModelId ?? undefined}
|
||||
onChange={(value: string) =>
|
||||
handleDefaultModelChange(AiModelRole.SMART, value)
|
||||
}
|
||||
options={availableModelOptions}
|
||||
selectSizeVariant="small"
|
||||
dropdownWidth={GenericDropdownContentWidth.ExtraLarge}
|
||||
/>
|
||||
</SettingsOptionCardContentSelect>
|
||||
<SettingsOptionCardContentSelect
|
||||
Icon={IconBolt}
|
||||
title={t`Fast Model`}
|
||||
description={t`Default model for lightweight tasks`}
|
||||
>
|
||||
<Select
|
||||
dropdownId="admin-fast-model-select"
|
||||
value={defaultFastModelId ?? undefined}
|
||||
onChange={(value: string) =>
|
||||
handleDefaultModelChange(AiModelRole.FAST, value)
|
||||
}
|
||||
options={availableModelOptions}
|
||||
selectSizeVariant="small"
|
||||
dropdownWidth={GenericDropdownContentWidth.ExtraLarge}
|
||||
/>
|
||||
</SettingsOptionCardContentSelect>
|
||||
</Card>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
<SearchInput
|
||||
placeholder={t`Search a model...`}
|
||||
value={searchQuery}
|
||||
onChange={setSearchQuery}
|
||||
filterDropdown={(filterButton) => (
|
||||
<Dropdown
|
||||
dropdownId="admin-ai-models-filter-dropdown"
|
||||
dropdownPlacement="bottom-end"
|
||||
dropdownOffset={{ x: 0, y: 8 }}
|
||||
clickableComponent={filterButton}
|
||||
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>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{enabledModels.length > 0 && (
|
||||
<Section>
|
||||
<H2Title
|
||||
title={t`Recommended Models`}
|
||||
description={t`Select which models appear as recommended in the workspace model picker`}
|
||||
/>
|
||||
|
||||
<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>
|
||||
<SettingsAdminAiModelsTable
|
||||
models={enabledModels}
|
||||
onToggle={handleRecommendedToggle}
|
||||
checkedField="isRecommended"
|
||||
anchorPrefix="recommended-model-row"
|
||||
/>
|
||||
</Section>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
+149
@@ -0,0 +1,149 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
+245
@@ -0,0 +1,245 @@
|
||||
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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
+91
@@ -0,0 +1,91 @@
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
|
||||
import { styled } from '@linaria/react';
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { SettingsPath } from 'twenty-shared/types';
|
||||
import { getSettingsPath } from 'twenty-shared/utils';
|
||||
import { IconPlug, Status } from 'twenty-ui/display';
|
||||
|
||||
import { type AiProviderItem } from '@/settings/admin-panel/ai/types/AiProviderItem';
|
||||
import { getProviderIcon } from '@/settings/admin-panel/ai/utils/getProviderIcon';
|
||||
import { SettingsCard } from '@/settings/components/SettingsCard';
|
||||
import { SettingsListCard } from '@/settings/components/SettingsListCard';
|
||||
|
||||
const StyledLinkContainer = styled.div`
|
||||
> a {
|
||||
text-decoration: none;
|
||||
}
|
||||
`;
|
||||
|
||||
type SettingsAdminAiProviderListCardProps = {
|
||||
providers: AiProviderItem[];
|
||||
showAddButton?: boolean;
|
||||
};
|
||||
|
||||
const getProviderDescription = (provider: AiProviderItem): string => {
|
||||
const parts: string[] = [];
|
||||
|
||||
if (provider.region) {
|
||||
parts.push(provider.region);
|
||||
}
|
||||
|
||||
if (provider.baseUrl) {
|
||||
parts.push(provider.baseUrl);
|
||||
}
|
||||
|
||||
if (provider.apiKey) {
|
||||
parts.push(t`API key configured`);
|
||||
} else if (provider.hasAccessKey) {
|
||||
parts.push(t`IAM credentials`);
|
||||
}
|
||||
|
||||
return parts.join(' · ');
|
||||
};
|
||||
|
||||
export const SettingsAdminAiProviderListCard = ({
|
||||
providers,
|
||||
showAddButton = true,
|
||||
}: SettingsAdminAiProviderListCardProps) => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
if (providers.length === 0 && showAddButton) {
|
||||
return (
|
||||
<StyledLinkContainer>
|
||||
<Link to={getSettingsPath(SettingsPath.AdminPanelNewAiProvider)}>
|
||||
<SettingsCard title={t`Add Custom Provider`} Icon={<IconPlug />} />
|
||||
</Link>
|
||||
</StyledLinkContainer>
|
||||
);
|
||||
}
|
||||
|
||||
if (providers.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsListCard
|
||||
items={providers}
|
||||
rounded
|
||||
RowIconFn={(provider) => getProviderIcon(provider.name ?? provider.id)}
|
||||
getItemLabel={(provider) => provider.label ?? provider.id}
|
||||
getItemDescription={getProviderDescription}
|
||||
RowRightComponent={({ item: provider }) =>
|
||||
provider.apiKey || provider.hasAccessKey ? (
|
||||
<Status color="green" text={t`Configured`} weight="medium" />
|
||||
) : (
|
||||
<Status color="orange" text={t`No credentials`} weight="medium" />
|
||||
)
|
||||
}
|
||||
to={(provider) =>
|
||||
getSettingsPath(SettingsPath.AdminPanelAiProviderDetail, {
|
||||
providerName: provider.id,
|
||||
})
|
||||
}
|
||||
hasFooter={showAddButton}
|
||||
footerButtonLabel={t`Add Custom Provider`}
|
||||
onFooterButtonClick={() =>
|
||||
navigate(getSettingsPath(SettingsPath.AdminPanelNewAiProvider))
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
import { SettingsPath } from 'twenty-shared/types';
|
||||
import { getSettingsPath } from 'twenty-shared/utils';
|
||||
|
||||
export const AI_ADMIN_PATH = getSettingsPath(
|
||||
SettingsPath.AdminPanel,
|
||||
undefined,
|
||||
undefined,
|
||||
'ai',
|
||||
);
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
export const AI_PROVIDER_SOURCE = {
|
||||
CATALOG: 'catalog',
|
||||
CUSTOM: 'custom',
|
||||
} as const;
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
import { type DataResidency } from 'twenty-shared/ai';
|
||||
|
||||
export const DATA_RESIDENCY_CONFIG: Record<
|
||||
DataResidency,
|
||||
{ label: string; flag: string }
|
||||
> = {
|
||||
us: { label: 'United States', flag: '🇺🇸' },
|
||||
eu: { label: 'European Union', flag: '🇪🇺' },
|
||||
global: { label: 'Global', flag: '🌐' },
|
||||
uk: { label: 'United Kingdom', flag: '🇬🇧' },
|
||||
ap: { label: 'Asia Pacific', flag: '🌏' },
|
||||
jp: { label: 'Japan', flag: '🇯🇵' },
|
||||
au: { label: 'Australia', flag: '🇦🇺' },
|
||||
ca: { label: 'Canada', flag: '🇨🇦' },
|
||||
de: { label: 'Germany', flag: '🇩🇪' },
|
||||
fr: { label: 'France', flag: '🇫🇷' },
|
||||
};
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
import { type DataResidency } from 'twenty-shared/ai';
|
||||
|
||||
import { DATA_RESIDENCY_CONFIG } from '@/settings/admin-panel/ai/constants/DataResidencyConfig';
|
||||
|
||||
export const DATA_RESIDENCY_OPTIONS = (
|
||||
Object.keys(DATA_RESIDENCY_CONFIG) as DataResidency[]
|
||||
).map((key) => ({
|
||||
value: key,
|
||||
label: `${DATA_RESIDENCY_CONFIG[key].flag} ${DATA_RESIDENCY_CONFIG[key].label}`,
|
||||
}));
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
import {
|
||||
IconBrandGemini,
|
||||
IconBrandMistral,
|
||||
IconBrandXai,
|
||||
IconModelClaude,
|
||||
IconProviderOpenai,
|
||||
IconRobot,
|
||||
type IconComponent,
|
||||
} from 'twenty-ui/display';
|
||||
|
||||
import { ModelFamily } from '~/generated-metadata/graphql';
|
||||
|
||||
export type ModelIconConfigKey = ModelFamily | 'FALLBACK';
|
||||
|
||||
export const MODEL_ICON_CONFIG: Record<ModelIconConfigKey, IconComponent> = {
|
||||
[ModelFamily.GPT]: IconProviderOpenai,
|
||||
[ModelFamily.CLAUDE]: IconModelClaude,
|
||||
[ModelFamily.GEMINI]: IconBrandGemini,
|
||||
[ModelFamily.MISTRAL]: IconBrandMistral,
|
||||
[ModelFamily.GROK]: IconBrandXai,
|
||||
FALLBACK: IconRobot,
|
||||
};
|
||||
@@ -0,0 +1,19 @@
|
||||
import {
|
||||
IconBrandAnthropic,
|
||||
IconBrandMistral,
|
||||
IconBrandXai,
|
||||
IconGoogle,
|
||||
IconProviderOpenai,
|
||||
IconRobot,
|
||||
type IconComponent,
|
||||
} from 'twenty-ui/display';
|
||||
|
||||
export const PROVIDER_ICON_CONFIG: Record<string, { Icon: IconComponent }> = {
|
||||
openai: { Icon: IconProviderOpenai },
|
||||
anthropic: { Icon: IconBrandAnthropic },
|
||||
bedrock: { Icon: IconRobot },
|
||||
google: { Icon: IconGoogle },
|
||||
mistral: { Icon: IconBrandMistral },
|
||||
xai: { Icon: IconBrandXai },
|
||||
'openai-compatible': { Icon: IconProviderOpenai },
|
||||
};
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
import { gql } from '@apollo/client';
|
||||
|
||||
export const ADD_AI_PROVIDER = gql`
|
||||
mutation AddAiProvider($providerName: String!, $providerConfig: JSON!) {
|
||||
addAiProvider(providerName: $providerName, providerConfig: $providerConfig)
|
||||
}
|
||||
`;
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
import { gql } from '@apollo/client';
|
||||
|
||||
export const ADD_MODEL_TO_PROVIDER = gql`
|
||||
mutation AddModelToProvider($providerName: String!, $modelConfig: JSON!) {
|
||||
addModelToProvider(providerName: $providerName, modelConfig: $modelConfig)
|
||||
}
|
||||
`;
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
import { gql } from '@apollo/client';
|
||||
|
||||
export const REMOVE_AI_PROVIDER = gql`
|
||||
mutation RemoveAiProvider($providerName: String!) {
|
||||
removeAiProvider(providerName: $providerName)
|
||||
}
|
||||
`;
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
import { gql } from '@apollo/client';
|
||||
|
||||
export const REMOVE_MODEL_FROM_PROVIDER = gql`
|
||||
mutation RemoveModelFromProvider(
|
||||
$providerName: String!
|
||||
$modelName: String!
|
||||
) {
|
||||
removeModelFromProvider(providerName: $providerName, modelName: $modelName)
|
||||
}
|
||||
`;
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
import { gql } from '@apollo/client';
|
||||
|
||||
export const SET_ADMIN_AI_MODEL_RECOMMENDED = gql`
|
||||
mutation SetAdminAiModelRecommended(
|
||||
$modelId: String!
|
||||
$recommended: Boolean!
|
||||
) {
|
||||
setAdminAiModelRecommended(modelId: $modelId, recommended: $recommended)
|
||||
}
|
||||
`;
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
import { gql } from '@apollo/client';
|
||||
|
||||
export const SET_ADMIN_DEFAULT_AI_MODEL = gql`
|
||||
mutation SetAdminDefaultAiModel($role: AiModelRole!, $modelId: String!) {
|
||||
setAdminDefaultAiModel(role: $role, modelId: $modelId)
|
||||
}
|
||||
`;
|
||||
+12
-3
@@ -3,16 +3,25 @@ import { gql } from '@apollo/client';
|
||||
export const GET_ADMIN_AI_MODELS = gql`
|
||||
query GetAdminAiModels {
|
||||
getAdminAiModels {
|
||||
autoEnableNewModels
|
||||
defaultSmartModelId
|
||||
defaultFastModelId
|
||||
models {
|
||||
modelId
|
||||
label
|
||||
modelFamily
|
||||
inferenceProvider
|
||||
sdkPackage
|
||||
isAvailable
|
||||
isAdminEnabled
|
||||
deprecated
|
||||
isDeprecated
|
||||
isRecommended
|
||||
contextWindowTokens
|
||||
maxOutputTokens
|
||||
inputCostPerMillionTokens
|
||||
outputCostPerMillionTokens
|
||||
providerName
|
||||
providerLabel
|
||||
name
|
||||
dataResidency
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
import { gql } from '@apollo/client';
|
||||
|
||||
export const GET_AI_PROVIDERS = gql`
|
||||
query GetAiProviders {
|
||||
getAiProviders
|
||||
}
|
||||
`;
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
import { gql } from '@apollo/client';
|
||||
|
||||
export const GET_MODELS_DEV_PROVIDERS = gql`
|
||||
query GetModelsDevProviders {
|
||||
getModelsDevProviders {
|
||||
id
|
||||
modelCount
|
||||
npm
|
||||
}
|
||||
}
|
||||
`;
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
import { gql } from '@apollo/client';
|
||||
|
||||
export const GET_MODELS_DEV_SUGGESTIONS = gql`
|
||||
query GetModelsDevSuggestions($providerType: String!) {
|
||||
getModelsDevSuggestions(providerType: $providerType) {
|
||||
modelId
|
||||
name
|
||||
inputCostPerMillionTokens
|
||||
outputCostPerMillionTokens
|
||||
cachedInputCostPerMillionTokens
|
||||
cacheCreationCostPerMillionTokens
|
||||
contextWindowTokens
|
||||
maxOutputTokens
|
||||
modalities
|
||||
supportsReasoning
|
||||
}
|
||||
}
|
||||
`;
|
||||
@@ -0,0 +1,23 @@
|
||||
import { type AiSdkPackage, type DataResidency } from 'twenty-shared/ai';
|
||||
|
||||
import { type AiProviderSource } from '@/settings/admin-panel/ai/types/AiProviderSource';
|
||||
|
||||
// AiProviderItem = RawAiProviderConfig (from the backend's Record<string,
|
||||
// RawAiProviderConfig>) enriched with the `id` key (same as the Record key).
|
||||
// Fields are defined here; RawAiProviderConfig is Omit<AiProviderItem, 'id'>.
|
||||
export type AiProviderItem = {
|
||||
id: string;
|
||||
npm: AiSdkPackage;
|
||||
// Optional provider display/catalog name from config (not a model name; models use `models[].name` on the backend).
|
||||
name?: string;
|
||||
label?: string;
|
||||
source?: AiProviderSource;
|
||||
baseUrl?: string;
|
||||
region?: string;
|
||||
dataResidency?: DataResidency;
|
||||
apiKey?: string;
|
||||
apiKeyConfigVariable?: string;
|
||||
accessKeyId?: string;
|
||||
secretAccessKey?: string;
|
||||
hasAccessKey?: boolean;
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export type AiProviderSource = 'catalog' | 'custom';
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
import { type RawAiProviderConfig } from '@/settings/admin-panel/ai/types/RawAiProviderConfig';
|
||||
|
||||
export type GetAiProvidersResult = {
|
||||
getAiProviders: Record<string, RawAiProviderConfig>;
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
import { type AiProviderItem } from '@/settings/admin-panel/ai/types/AiProviderItem';
|
||||
|
||||
// Backend stores providers as Record<providerId, RawAiProviderConfig>; the id is the
|
||||
// record key, not a field on the value. Same shape as AiProviderItem minus `id`.
|
||||
export type RawAiProviderConfig = Omit<AiProviderItem, 'id'>;
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
import { isDataResidency } from 'twenty-shared/ai';
|
||||
|
||||
import { DATA_RESIDENCY_CONFIG } from '@/settings/admin-panel/ai/constants/DataResidencyConfig';
|
||||
|
||||
export const getDataResidencyDisplay = (residency: string): string => {
|
||||
if (isDataResidency(residency)) {
|
||||
const entry = DATA_RESIDENCY_CONFIG[residency];
|
||||
|
||||
return `${entry.flag} ${entry.label}`;
|
||||
}
|
||||
|
||||
return `🌐 ${residency.toUpperCase()}`;
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
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 { type ModelFamily } from '~/generated-metadata/graphql';
|
||||
|
||||
export const getModelIcon = (
|
||||
modelFamily: ModelFamily | null | undefined,
|
||||
providerName?: string | null,
|
||||
): IconComponent => {
|
||||
if (modelFamily && isModelIconKey(modelFamily)) {
|
||||
return MODEL_ICON_CONFIG[modelFamily];
|
||||
}
|
||||
|
||||
if (providerName) {
|
||||
return getProviderIcon(providerName);
|
||||
}
|
||||
|
||||
return MODEL_ICON_CONFIG.FALLBACK;
|
||||
};
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
import { type IconComponent, type IconComponentProps } from 'twenty-ui/display';
|
||||
|
||||
import { ModelsDevProviderLogo } from '@/settings/admin-panel/ai/components/ModelsDevProviderLogo';
|
||||
|
||||
const MODELS_DEV_LOGO_BASE = 'https://models.dev/logos';
|
||||
|
||||
const logoIconCache = new Map<string, IconComponent>();
|
||||
|
||||
type LogoIconProps = IconComponentProps;
|
||||
|
||||
export const getModelsDevLogoIcon = (providerId: string): IconComponent => {
|
||||
const cached = logoIconCache.get(providerId);
|
||||
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const logoUrl = `${MODELS_DEV_LOGO_BASE}/${providerId}.svg`;
|
||||
|
||||
const LogoIcon = ({ size = 16, className, style }: LogoIconProps) => (
|
||||
<ModelsDevProviderLogo
|
||||
className={className}
|
||||
logoUrl={logoUrl}
|
||||
size={size}
|
||||
style={style}
|
||||
/>
|
||||
);
|
||||
|
||||
LogoIcon.displayName = `ModelsDevLogo(${providerId})`;
|
||||
|
||||
logoIconCache.set(providerId, LogoIcon);
|
||||
|
||||
return LogoIcon;
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
import { type IconComponent } from 'twenty-ui/display';
|
||||
|
||||
import { PROVIDER_ICON_CONFIG } from '@/settings/admin-panel/ai/constants/ProviderConfig';
|
||||
import { isKnownProviderId } from '@/settings/admin-panel/ai/utils/isKnownProviderId';
|
||||
import { getModelsDevLogoIcon } from '@/settings/admin-panel/ai/utils/getModelsDevLogoIcon';
|
||||
|
||||
export const getProviderIcon = (providerType: string): IconComponent =>
|
||||
isKnownProviderId(providerType)
|
||||
? PROVIDER_ICON_CONFIG[providerType].Icon
|
||||
: getModelsDevLogoIcon(providerType);
|
||||
@@ -0,0 +1,6 @@
|
||||
import { PROVIDER_ICON_CONFIG } from '@/settings/admin-panel/ai/constants/ProviderConfig';
|
||||
|
||||
export type KnownProviderId = keyof typeof PROVIDER_ICON_CONFIG;
|
||||
|
||||
export const isKnownProviderId = (id: string): id is KnownProviderId =>
|
||||
id in PROVIDER_ICON_CONFIG;
|
||||
@@ -0,0 +1,7 @@
|
||||
import {
|
||||
MODEL_ICON_CONFIG,
|
||||
type ModelIconConfigKey,
|
||||
} from '@/settings/admin-panel/ai/constants/ModelIconConfig';
|
||||
|
||||
export const isModelIconKey = (key: string): key is ModelIconConfigKey =>
|
||||
key in MODEL_ICON_CONFIG;
|
||||
@@ -0,0 +1,10 @@
|
||||
import { type AiProviderItem } from '@/settings/admin-panel/ai/types/AiProviderItem';
|
||||
import { type RawAiProviderConfig } from '@/settings/admin-panel/ai/types/RawAiProviderConfig';
|
||||
|
||||
export const parseProviderItems = (
|
||||
rawProviders: Record<string, RawAiProviderConfig>,
|
||||
): AiProviderItem[] =>
|
||||
Object.entries(rawProviders).map(([key, config]) => ({
|
||||
...config,
|
||||
id: key,
|
||||
}));
|
||||
+46
-1
@@ -5,17 +5,34 @@ import { TextInput } from '@/ui/input/components/TextInput';
|
||||
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 { styled } from '@linaria/react';
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { type ConfigVariableValue } from 'twenty-shared/types';
|
||||
import { CustomError } from 'twenty-shared/utils';
|
||||
import { CodeEditor } from 'twenty-ui/input';
|
||||
import { MenuItemMultiSelect } from 'twenty-ui/navigation';
|
||||
import { themeCssVariables } from 'twenty-ui/theme-constants';
|
||||
import { ConfigVariableType } from '~/generated-metadata/graphql';
|
||||
import { type ConfigVariableOptions } from '@/settings/admin-panel/config-variables/types/ConfigVariableOptions';
|
||||
|
||||
const StyledJsonEditorContainer = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const StyledJsonEditorLabel = styled.span`
|
||||
color: ${themeCssVariables.font.color.light};
|
||||
display: block;
|
||||
font-size: ${themeCssVariables.font.size.xs};
|
||||
font-weight: ${themeCssVariables.font.weight.semiBold};
|
||||
margin-bottom: ${themeCssVariables.spacing[1]};
|
||||
`;
|
||||
|
||||
type ConfigVariableDatabaseInputProps = {
|
||||
label: string;
|
||||
value: ConfigVariableValue;
|
||||
onChange: (value: string | number | boolean | string[] | null) => void;
|
||||
onChange: (value: ConfigVariableValue) => void;
|
||||
type: ConfigVariableType;
|
||||
options?: ConfigVariableOptions;
|
||||
disabled?: boolean;
|
||||
@@ -190,6 +207,34 @@ export const ConfigVariableDatabaseInput = ({
|
||||
/>
|
||||
);
|
||||
|
||||
case ConfigVariableType.JSON:
|
||||
return (
|
||||
<StyledJsonEditorContainer>
|
||||
<StyledJsonEditorLabel>{label}</StyledJsonEditorLabel>
|
||||
<CodeEditor
|
||||
value={
|
||||
typeof value === 'string'
|
||||
? value
|
||||
: value !== null && value !== undefined
|
||||
? JSON.stringify(value, null, 2)
|
||||
: ''
|
||||
}
|
||||
language="json"
|
||||
height="200px"
|
||||
options={{
|
||||
readOnly: disabled === true,
|
||||
}}
|
||||
onChange={(text) => {
|
||||
try {
|
||||
onChange(JSON.parse(text) as Record<string, unknown>);
|
||||
} catch {
|
||||
onChange(text as unknown as ConfigVariableValue);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</StyledJsonEditorContainer>
|
||||
);
|
||||
|
||||
default:
|
||||
throw new CustomError(`Unsupported type: ${type}`, 'UNSUPPORTED_TYPE');
|
||||
}
|
||||
|
||||
+1
-1
@@ -11,7 +11,7 @@ import { ConfigVariableDatabaseInput } from './ConfigVariableDatabaseInput';
|
||||
type ConfigVariableValueInputProps = {
|
||||
variable: ConfigVariable;
|
||||
value: ConfigVariableValue;
|
||||
onChange: (value: string | number | boolean | string[] | null) => void;
|
||||
onChange: (value: ConfigVariableValue) => void;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
|
||||
+3
-1
@@ -40,7 +40,9 @@ export const SettingsAdminConfigVariablesRow = ({
|
||||
? variable.value
|
||||
? 'true'
|
||||
: 'false'
|
||||
: variable.value;
|
||||
: typeof variable.value === 'object' && variable.value !== null
|
||||
? JSON.stringify(variable.value)
|
||||
: variable.value;
|
||||
|
||||
return (
|
||||
<StyledTableRowContainer>
|
||||
|
||||
+5
-1
@@ -16,6 +16,7 @@ export const useConfigVariableForm = (variable?: ConfigVariable) => {
|
||||
z.number(),
|
||||
z.boolean(),
|
||||
z.array(z.string()),
|
||||
z.record(z.string(), z.unknown()),
|
||||
z.null(),
|
||||
]),
|
||||
});
|
||||
@@ -39,7 +40,10 @@ export const useConfigVariableForm = (variable?: ConfigVariable) => {
|
||||
((typeof currentValue === 'string' && currentValue.trim() !== '') ||
|
||||
typeof currentValue === 'boolean' ||
|
||||
typeof currentValue === 'number' ||
|
||||
(Array.isArray(currentValue) && currentValue.length > 0))
|
||||
(Array.isArray(currentValue) && currentValue.length > 0) ||
|
||||
(typeof currentValue === 'object' &&
|
||||
currentValue !== null &&
|
||||
!Array.isArray(currentValue)))
|
||||
);
|
||||
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user