Files
twenty/packages/twenty-front/src/modules/ai/components/internal/AIChatContextUsageButton.tsx
T
Félix Malfait 908aefe7c1 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)
2026-03-21 16:03:58 +01:00

241 lines
7.6 KiB
TypeScript

import { styled } from '@linaria/react';
import { t } from '@lingui/core/macro';
import { useLingui } from '@lingui/react/macro';
import { useState } from 'react';
import { isDefined } from 'twenty-shared/utils';
import { HorizontalSeparator } from 'twenty-ui/display';
import { ProgressBar } from 'twenty-ui/feedback';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { ContextUsageProgressRing } from '@/ai/components/internal/ContextUsageProgressRing';
import { agentChatHasMessageComponentSelector } from '@/ai/states/agentChatHasMessageComponentSelector';
import {
agentChatUsageState,
type AgentChatLastMessageUsage,
} from '@/ai/states/agentChatUsageState';
import { SettingsBillingLabelValueItem } from '@/billing/components/internal/SettingsBillingLabelValueItem';
import { useAtomComponentSelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentSelectorValue';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { formatNumber } from '~/utils/format/formatNumber';
const StyledContainer = styled.div`
position: relative;
`;
const StyledTrigger = styled.div<{ hasUsage: boolean }>`
align-items: center;
cursor: ${({ hasUsage }) => (hasUsage ? 'pointer' : 'default')};
display: flex;
height: 24px;
justify-content: center;
min-width: 24px;
transition: background calc(${themeCssVariables.animation.duration.fast} * 1s)
ease;
&:hover {
background: ${({ hasUsage }) =>
hasUsage
? themeCssVariables.background.transparent.light
: 'transparent'};
}
`;
const StyledHoverCard = styled.div`
background: ${themeCssVariables.background.primary};
border: 1px solid ${themeCssVariables.border.color.medium};
border-radius: ${themeCssVariables.border.radius.md};
bottom: calc(100% + 8px);
box-shadow: ${themeCssVariables.boxShadow.strong};
left: 0;
min-width: 280px;
position: absolute;
z-index: ${themeCssVariables.lastLayerZIndex};
`;
const StyledSection = styled.div`
display: flex;
flex-direction: column;
gap: ${themeCssVariables.spacing[2]};
padding: ${themeCssVariables.spacing[3]};
`;
const StyledRow = styled.div`
align-items: center;
display: flex;
justify-content: space-between;
`;
const StyledContextWindowValue = styled.span`
color: ${themeCssVariables.font.color.secondary};
font-size: ${themeCssVariables.font.size.sm};
font-weight: ${themeCssVariables.font.weight.medium};
`;
const StyledSectionTitle = styled.span`
color: ${themeCssVariables.font.color.primary};
font-size: ${themeCssVariables.font.size.xs};
font-weight: ${themeCssVariables.font.weight.semiBold};
padding-bottom: ${themeCssVariables.spacing[2]};
`;
const formatCredits = (credits: number): string => {
if (Number.isInteger(credits)) {
return credits.toLocaleString();
}
return credits.toLocaleString(undefined, {
minimumFractionDigits: 0,
maximumFractionDigits: 1,
});
};
const getCachedLabel = (lastMessage: AgentChatLastMessageUsage): string => {
if (lastMessage.cachedInputTokens <= 0 || lastMessage.inputTokens <= 0) {
return '';
}
const cachedPercent = Math.round(
(lastMessage.cachedInputTokens / lastMessage.inputTokens) * 100,
);
return ` (${t`${cachedPercent}% cached`})`;
};
export const AIChatContextUsageButton = () => {
const { t } = useLingui();
const [isHovered, setIsHovered] = useState(false);
const agentChatUsage = useAtomStateValue(agentChatUsageState);
const hasMessages = useAtomComponentSelectorValue(
agentChatHasMessageComponentSelector,
);
if (!hasMessages) {
return null;
}
if (!agentChatUsage) {
return (
<StyledContainer>
<StyledTrigger hasUsage={false}>
<ContextUsageProgressRing percentage={0} />
</StyledTrigger>
</StyledContainer>
);
}
const percentage = Math.min(
(agentChatUsage.conversationSize / agentChatUsage.contextWindowTokens) *
100,
100,
);
const formattedPercentage = percentage.toFixed(1);
const totalCredits =
agentChatUsage.inputCredits + agentChatUsage.outputCredits;
const lastMessage = agentChatUsage.lastMessage;
return (
<StyledContainer
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
<StyledTrigger hasUsage={true}>
<ContextUsageProgressRing percentage={percentage} />
</StyledTrigger>
{isHovered && (
<StyledHoverCard>
<StyledSection>
<StyledSectionTitle>{t`Context window`}</StyledSectionTitle>
<StyledRow>
<StyledContextWindowValue>
{formattedPercentage}%
</StyledContextWindowValue>
<StyledContextWindowValue>
{formatNumber(agentChatUsage.conversationSize, {
abbreviate: true,
decimals: 1,
})}{' '}
/{' '}
{formatNumber(agentChatUsage.contextWindowTokens, {
abbreviate: true,
decimals: 1,
})}{' '}
{t`tokens`}
</StyledContextWindowValue>
</StyledRow>
<ProgressBar
value={percentage}
barColor={
percentage > 80
? themeCssVariables.color.red
: percentage > 60
? themeCssVariables.color.orange
: themeCssVariables.color.blue
}
backgroundColor={themeCssVariables.background.tertiary}
withBorderRadius
/>
</StyledSection>
{isDefined(lastMessage) && (
<>
<HorizontalSeparator
noMargin
color={themeCssVariables.background.tertiary}
/>
<StyledSection>
<StyledSectionTitle>{t`Last message`}</StyledSectionTitle>
<SettingsBillingLabelValueItem
label={t`Input tokens`}
value={`${formatNumber(lastMessage.inputTokens, {
abbreviate: true,
decimals: 1,
})}${getCachedLabel(lastMessage)}`}
/>
<SettingsBillingLabelValueItem
label={t`Output tokens`}
value={formatNumber(lastMessage.outputTokens, {
abbreviate: true,
decimals: 1,
})}
/>
<SettingsBillingLabelValueItem
label={t`Cost`}
value={`${formatCredits(lastMessage.inputCredits + lastMessage.outputCredits)} ${t`credits`}`}
/>
</StyledSection>
</>
)}
<HorizontalSeparator
noMargin
color={themeCssVariables.background.tertiary}
/>
<StyledSection>
<StyledSectionTitle>{t`Conversation`}</StyledSectionTitle>
<SettingsBillingLabelValueItem
label={t`Input tokens`}
value={formatNumber(agentChatUsage.inputTokens, {
abbreviate: true,
decimals: 1,
})}
/>
<SettingsBillingLabelValueItem
label={t`Output tokens`}
value={formatNumber(agentChatUsage.outputTokens, {
abbreviate: true,
decimals: 1,
})}
/>
<SettingsBillingLabelValueItem
label={t`Total cost`}
value={`${formatCredits(totalCredits)} ${t`credits`}`}
/>
</StyledSection>
</StyledHoverCard>
)}
</StyledContainer>
);
};