Files
twenty/packages/twenty-front/src/modules/ai/components/internal/AIChatContextUsageButton.tsx
T
Félix Malfait 4f91b48470 feat(ai): add context usage display to AI chat (BREAKING: deploy server first) (#16518)
## Summary

- Add a context usage indicator to the AI chat interface inspired by
Vercel's AI SDK Context component
- Display token consumption, context window utilization percentage, and
estimated cost in credits
- Show a circular progress ring with percentage, revealing detailed
breakdown on hover

## Changes

### Backend
- Stream usage metadata (tokens, model config) via `messageMetadata`
callback in `agent-chat-streaming.service.ts`
- Return model config from `chat-execution.service.ts`
- Add usage and model types to `ExtendedUIMessage` metadata

### Frontend
- New `ContextUsageProgressRing` component - circular SVG progress
indicator
- New `AIChatContextUsageButton` component with hover card showing:
  - Progress bar with used/total tokens
  - Input/output token counts with credit costs
  - Total credits consumed
- Track cumulative usage in Recoil state (`agentChatUsageState`)
- Reset usage when creating new chat thread
- Integrate button into `AIChatTab`

## Test plan

- [ ] Open AI chat and send a message
- [ ] Verify the context usage button appears with percentage
- [ ] Hover over the button to see detailed breakdown
- [ ] Verify credits are calculated correctly
- [ ] Create a new chat thread and verify usage resets to 0
2025-12-12 13:42:00 +01:00

189 lines
5.7 KiB
TypeScript

import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { useState } from 'react';
import { useRecoilValue } from 'recoil';
import { ProgressBar } from 'twenty-ui/feedback';
import { ContextUsageProgressRing } from '@/ai/components/internal/ContextUsageProgressRing';
import { agentChatUsageState } from '@/ai/states/agentChatUsageState';
const StyledContainer = styled.div`
position: relative;
`;
const StyledTrigger = styled.div<{ hasUsage: boolean }>`
align-items: center;
background: transparent;
border: 1px solid ${({ theme }) => theme.background.transparent.medium};
border-radius: ${({ theme }) => theme.border.radius.sm};
cursor: ${({ hasUsage }) => (hasUsage ? 'pointer' : 'default')};
display: flex;
gap: ${({ theme }) => theme.spacing(1)};
height: 24px;
padding: 0 ${({ theme }) => theme.spacing(2)};
transition: background 0.1s ease;
&:hover {
background: ${({ theme, hasUsage }) =>
hasUsage ? theme.background.transparent.light : 'transparent'};
}
`;
const StyledPercentage = styled.span`
color: ${({ theme }) => theme.font.color.secondary};
font-size: ${({ theme }) => theme.font.size.sm};
font-weight: ${({ theme }) => theme.font.weight.medium};
`;
const StyledHoverCard = styled.div`
background: ${({ theme }) => theme.background.primary};
border: 1px solid ${({ theme }) => theme.border.color.medium};
border-radius: ${({ theme }) => theme.border.radius.md};
box-shadow: ${({ theme }) => theme.boxShadow.strong};
min-width: 240px;
position: absolute;
right: 0;
bottom: calc(100% + 8px);
z-index: ${({ theme }) => theme.lastLayerZIndex};
`;
const StyledHeader = styled.div`
display: flex;
flex-direction: column;
gap: ${({ theme }) => theme.spacing(2)};
padding: ${({ theme }) => theme.spacing(3)};
`;
const StyledRow = styled.div`
align-items: center;
display: flex;
justify-content: space-between;
`;
const StyledBody = styled.div`
display: flex;
flex-direction: column;
gap: ${({ theme }) => theme.spacing(2)};
padding: ${({ theme }) => theme.spacing(3)};
padding-top: 0;
`;
const StyledLabel = styled.span`
color: ${({ theme }) => theme.font.color.secondary};
font-size: ${({ theme }) => theme.font.size.sm};
`;
const StyledValue = styled.span`
color: ${({ theme }) => theme.font.color.tertiary};
font-size: ${({ theme }) => theme.font.size.sm};
`;
const StyledFooter = styled.div`
align-items: center;
background: ${({ theme }) => theme.background.secondary};
border-top: 1px solid ${({ theme }) => theme.border.color.light};
border-radius: 0 0 ${({ theme }) => theme.border.radius.md}
${({ theme }) => theme.border.radius.md};
display: flex;
justify-content: space-between;
padding: ${({ theme }) => theme.spacing(3)};
`;
const formatTokenCount = (count: number): string => {
if (count >= 1_000_000_000) {
return `${(count / 1_000_000_000).toFixed(1)}B`;
}
if (count >= 1_000_000) {
return `${(count / 1_000_000).toFixed(1)}M`;
}
if (count >= 1_000) {
return `${(count / 1_000).toFixed(1)}K`;
}
return count.toString();
};
export const AIChatContextUsageButton = () => {
const theme = useTheme();
const [isHovered, setIsHovered] = useState(false);
const agentChatUsage = useRecoilValue(agentChatUsageState);
if (!agentChatUsage) {
return (
<StyledContainer>
<StyledTrigger hasUsage={false}>
<ContextUsageProgressRing percentage={0} />
<StyledPercentage>0%</StyledPercentage>
</StyledTrigger>
</StyledContainer>
);
}
const percentage = Math.min(
(agentChatUsage.totalTokens / agentChatUsage.contextWindowTokens) * 100,
100,
);
const formattedPercentage = percentage.toFixed(1);
const totalCredits =
agentChatUsage.inputCredits + agentChatUsage.outputCredits;
return (
<StyledContainer
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
<StyledTrigger hasUsage={true}>
<ContextUsageProgressRing percentage={percentage} />
<StyledPercentage>{formattedPercentage}%</StyledPercentage>
</StyledTrigger>
{isHovered && (
<StyledHoverCard>
<StyledHeader>
<StyledRow>
<StyledPercentage>{formattedPercentage}%</StyledPercentage>
<StyledValue>
{formatTokenCount(agentChatUsage.totalTokens)} /{' '}
{formatTokenCount(agentChatUsage.contextWindowTokens)}
</StyledValue>
</StyledRow>
<ProgressBar
value={percentage}
barColor={
percentage > 80
? theme.color.red
: percentage > 60
? theme.color.orange
: theme.color.blue
}
backgroundColor={theme.background.quaternary}
withBorderRadius
/>
</StyledHeader>
<StyledBody>
<StyledRow>
<StyledLabel>Input</StyledLabel>
<StyledValue>
{formatTokenCount(agentChatUsage.inputTokens)} {' '}
{agentChatUsage.inputCredits.toLocaleString()} credits
</StyledValue>
</StyledRow>
<StyledRow>
<StyledLabel>Output</StyledLabel>
<StyledValue>
{formatTokenCount(agentChatUsage.outputTokens)} {' '}
{agentChatUsage.outputCredits.toLocaleString()} credits
</StyledValue>
</StyledRow>
</StyledBody>
<StyledFooter>
<StyledLabel>Total credits</StyledLabel>
<StyledPercentage>{totalCredits.toLocaleString()}</StyledPercentage>
</StyledFooter>
</StyledHoverCard>
)}
</StyledContainer>
);
};