4f91b48470
## 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
189 lines
5.7 KiB
TypeScript
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>
|
|
);
|
|
};
|