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
This commit is contained in:
@@ -70,9 +70,14 @@ export type Agent = {
|
||||
|
||||
export type AgentChatThread = {
|
||||
__typename?: 'AgentChatThread';
|
||||
contextWindowTokens?: Maybe<Scalars['Int']>;
|
||||
createdAt: Scalars['DateTime'];
|
||||
id: Scalars['UUID'];
|
||||
title?: Maybe<Scalars['String']>;
|
||||
totalInputCredits: Scalars['Int'];
|
||||
totalInputTokens: Scalars['Int'];
|
||||
totalOutputCredits: Scalars['Int'];
|
||||
totalOutputTokens: Scalars['Int'];
|
||||
updatedAt: Scalars['DateTime'];
|
||||
};
|
||||
|
||||
@@ -5114,7 +5119,7 @@ export type GetChatMessagesQuery = { __typename?: 'Query', chatMessages: Array<{
|
||||
export type GetChatThreadsQueryVariables = Exact<{ [key: string]: never; }>;
|
||||
|
||||
|
||||
export type GetChatThreadsQuery = { __typename?: 'Query', chatThreads: Array<{ __typename?: 'AgentChatThread', id: string, title?: string | null, createdAt: string, updatedAt: string }> };
|
||||
export type GetChatThreadsQuery = { __typename?: 'Query', chatThreads: Array<{ __typename?: 'AgentChatThread', id: string, title?: string | null, totalInputTokens: number, totalOutputTokens: number, contextWindowTokens?: number | null, totalInputCredits: number, totalOutputCredits: number, createdAt: string, updatedAt: string }> };
|
||||
|
||||
export type TrackAnalyticsMutationVariables = Exact<{
|
||||
type: AnalyticsType;
|
||||
@@ -7722,6 +7727,11 @@ export const GetChatThreadsDocument = gql`
|
||||
chatThreads {
|
||||
id
|
||||
title
|
||||
totalInputTokens
|
||||
totalOutputTokens
|
||||
contextWindowTokens
|
||||
totalInputCredits
|
||||
totalOutputCredits
|
||||
createdAt
|
||||
updatedAt
|
||||
}
|
||||
|
||||
@@ -70,9 +70,14 @@ export type Agent = {
|
||||
|
||||
export type AgentChatThread = {
|
||||
__typename?: 'AgentChatThread';
|
||||
contextWindowTokens?: Maybe<Scalars['Int']>;
|
||||
createdAt: Scalars['DateTime'];
|
||||
id: Scalars['UUID'];
|
||||
title?: Maybe<Scalars['String']>;
|
||||
totalInputCredits: Scalars['Int'];
|
||||
totalInputTokens: Scalars['Int'];
|
||||
totalOutputCredits: Scalars['Int'];
|
||||
totalOutputTokens: Scalars['Int'];
|
||||
updatedAt: Scalars['DateTime'];
|
||||
};
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import { ScrollWrapper } from '@/ui/utilities/scroll/components/ScrollWrapper';
|
||||
|
||||
import { AIChatEmptyState } from '@/ai/components/AIChatEmptyState';
|
||||
import { AIChatMessage } from '@/ai/components/AIChatMessage';
|
||||
import { AIChatContextUsageButton } from '@/ai/components/internal/AIChatContextUsageButton';
|
||||
import { AIChatSkeletonLoader } from '@/ai/components/internal/AIChatSkeletonLoader';
|
||||
import { AgentChatContextPreview } from '@/ai/components/internal/AgentChatContextPreview';
|
||||
import { SendMessageButton } from '@/ai/components/internal/SendMessageButton';
|
||||
@@ -138,6 +139,7 @@ export const AIChatTab = () => {
|
||||
onClick={() => createChatThread()}
|
||||
/>
|
||||
<AgentChatFileUploadButton />
|
||||
<AIChatContextUsageButton />
|
||||
<SendMessageButton />
|
||||
</StyledButtonsContainer>
|
||||
</StyledInputArea>
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { agentChatUsageState } from '@/ai/states/agentChatUsageState';
|
||||
import { currentAIChatThreadState } from '@/ai/states/currentAIChatThreadState';
|
||||
import { useOpenAskAIPageInCommandMenu } from '@/command-menu/hooks/useOpenAskAIPageInCommandMenu';
|
||||
import { useTheme } from '@emotion/react';
|
||||
import styled from '@emotion/styled';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { useRecoilState } from 'recoil';
|
||||
import { useRecoilState, useSetRecoilState } from 'recoil';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { IconSparkles } from 'twenty-ui/display';
|
||||
import { type AgentChatThread } from '~/generated-metadata/graphql';
|
||||
|
||||
@@ -76,8 +78,35 @@ export const AIChatThreadGroup = ({
|
||||
const { t } = useLingui();
|
||||
const theme = useTheme();
|
||||
const [, setCurrentAIChatThread] = useRecoilState(currentAIChatThreadState);
|
||||
const setAgentChatUsage = useSetRecoilState(agentChatUsageState);
|
||||
const { openAskAIPage } = useOpenAskAIPageInCommandMenu();
|
||||
|
||||
const handleThreadClick = (thread: AgentChatThread) => {
|
||||
setCurrentAIChatThread(thread.id);
|
||||
|
||||
const totalTokens = thread.totalInputTokens + thread.totalOutputTokens;
|
||||
const hasUsageData =
|
||||
totalTokens > 0 && isDefined(thread.contextWindowTokens);
|
||||
|
||||
setAgentChatUsage(
|
||||
hasUsageData
|
||||
? {
|
||||
inputTokens: thread.totalInputTokens,
|
||||
outputTokens: thread.totalOutputTokens,
|
||||
totalTokens,
|
||||
contextWindowTokens: thread.contextWindowTokens ?? 0,
|
||||
inputCredits: thread.totalInputCredits,
|
||||
outputCredits: thread.totalOutputCredits,
|
||||
}
|
||||
: null,
|
||||
);
|
||||
|
||||
openAskAIPage({
|
||||
pageTitle: thread.title,
|
||||
resetNavigationStack: false,
|
||||
});
|
||||
};
|
||||
|
||||
if (threads.length === 0) {
|
||||
return null;
|
||||
}
|
||||
@@ -88,13 +117,7 @@ export const AIChatThreadGroup = ({
|
||||
<StyledThreadsList>
|
||||
{threads.map((thread) => (
|
||||
<StyledThreadItem
|
||||
onClick={() => {
|
||||
setCurrentAIChatThread(thread.id);
|
||||
openAskAIPage({
|
||||
pageTitle: thread.title,
|
||||
resetNavigationStack: false,
|
||||
});
|
||||
}}
|
||||
onClick={() => handleThreadClick(thread)}
|
||||
key={thread.id}
|
||||
>
|
||||
<StyledSparkleIcon>
|
||||
|
||||
+188
@@ -0,0 +1,188 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,64 @@
|
||||
import { useTheme } from '@emotion/react';
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
type ContextUsageProgressRingProps = {
|
||||
percentage: number;
|
||||
size?: number;
|
||||
strokeWidth?: number;
|
||||
};
|
||||
|
||||
const StyledSvg = styled.svg`
|
||||
transform: rotate(-90deg);
|
||||
`;
|
||||
|
||||
const StyledBackgroundCircle = styled.circle`
|
||||
fill: none;
|
||||
stroke: ${({ theme }) => theme.background.quaternary};
|
||||
`;
|
||||
|
||||
const StyledProgressCircle = styled.circle`
|
||||
fill: none;
|
||||
transition: stroke-dashoffset 0.3s ease;
|
||||
`;
|
||||
|
||||
export const ContextUsageProgressRing = ({
|
||||
percentage,
|
||||
size = 16,
|
||||
strokeWidth = 2,
|
||||
}: ContextUsageProgressRingProps) => {
|
||||
const theme = useTheme();
|
||||
|
||||
const normalizedPercentage = Math.min(Math.max(percentage, 0), 100);
|
||||
const radius = (size - strokeWidth) / 2;
|
||||
const circumference = 2 * Math.PI * radius;
|
||||
const strokeDashoffset =
|
||||
circumference - (normalizedPercentage / 100) * circumference;
|
||||
|
||||
const progressColor =
|
||||
normalizedPercentage > 80
|
||||
? theme.color.red
|
||||
: normalizedPercentage > 60
|
||||
? theme.color.orange
|
||||
: theme.color.blue;
|
||||
|
||||
return (
|
||||
<StyledSvg width={size} height={size}>
|
||||
<StyledBackgroundCircle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={radius}
|
||||
strokeWidth={strokeWidth}
|
||||
/>
|
||||
<StyledProgressCircle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={radius}
|
||||
strokeWidth={strokeWidth}
|
||||
stroke={progressColor}
|
||||
strokeDasharray={circumference}
|
||||
strokeDashoffset={strokeDashoffset}
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</StyledSvg>
|
||||
);
|
||||
};
|
||||
@@ -5,6 +5,11 @@ export const GET_CHAT_THREADS = gql`
|
||||
chatThreads {
|
||||
id
|
||||
title
|
||||
totalInputTokens
|
||||
totalOutputTokens
|
||||
contextWindowTokens
|
||||
totalInputCredits
|
||||
totalOutputCredits
|
||||
createdAt
|
||||
updatedAt
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useRecoilState, useRecoilValue, useSetRecoilState } from 'recoil';
|
||||
import { useGetBrowsingContext } from '@/ai/hooks/useBrowsingContext';
|
||||
import { agentChatSelectedFilesState } from '@/ai/states/agentChatSelectedFilesState';
|
||||
import { agentChatUploadedFilesState } from '@/ai/states/agentChatUploadedFilesState';
|
||||
import { agentChatUsageState } from '@/ai/states/agentChatUsageState';
|
||||
import { currentAIChatThreadState } from '@/ai/states/currentAIChatThreadState';
|
||||
|
||||
import { getTokenPair } from '@/apollo/utils/getTokenPair';
|
||||
@@ -19,6 +20,7 @@ import { agentChatInputState } from '../states/agentChatInputState';
|
||||
|
||||
export const useAgentChat = (uiMessages: ExtendedUIMessage[]) => {
|
||||
const setTokenPair = useSetRecoilState(tokenPairState);
|
||||
const setAgentChatUsage = useSetRecoilState(agentChatUsageState);
|
||||
|
||||
const { getBrowsingContext } = useGetBrowsingContext();
|
||||
|
||||
@@ -99,6 +101,34 @@ export const useAgentChat = (uiMessages: ExtendedUIMessage[]) => {
|
||||
messages: uiMessages,
|
||||
id: `${currentAIChatThread}-${uiMessages.length}`,
|
||||
experimental_throttle: 100,
|
||||
onFinish: ({ message }) => {
|
||||
type UsageMetadata = {
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
inputCredits: number;
|
||||
outputCredits: number;
|
||||
};
|
||||
type ModelMetadata = {
|
||||
contextWindowTokens: number;
|
||||
};
|
||||
const metadata = message.metadata as
|
||||
| { usage?: UsageMetadata; model?: ModelMetadata }
|
||||
| undefined;
|
||||
const usage = metadata?.usage;
|
||||
const model = metadata?.model;
|
||||
|
||||
if (isDefined(usage) && isDefined(model)) {
|
||||
setAgentChatUsage((prev) => ({
|
||||
inputTokens: (prev?.inputTokens ?? 0) + usage.inputTokens,
|
||||
outputTokens: (prev?.outputTokens ?? 0) + usage.outputTokens,
|
||||
totalTokens:
|
||||
(prev?.totalTokens ?? 0) + usage.inputTokens + usage.outputTokens,
|
||||
contextWindowTokens: model.contextWindowTokens,
|
||||
inputCredits: (prev?.inputCredits ?? 0) + usage.inputCredits,
|
||||
outputCredits: (prev?.outputCredits ?? 0) + usage.outputCredits,
|
||||
}));
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const isStreaming = status === 'streaming';
|
||||
|
||||
@@ -1,17 +1,48 @@
|
||||
import { useAgentChatScrollToBottom } from '@/ai/hooks/useAgentChatScrollToBottom';
|
||||
import {
|
||||
agentChatUsageState,
|
||||
type AgentChatUsageState,
|
||||
} from '@/ai/states/agentChatUsageState';
|
||||
import { currentAIChatThreadState } from '@/ai/states/currentAIChatThreadState';
|
||||
import { mapDBMessagesToUIMessages } from '@/ai/utils/mapDBMessagesToUIMessages';
|
||||
import { useRecoilState } from 'recoil';
|
||||
import {
|
||||
type SetterOrUpdater,
|
||||
useRecoilState,
|
||||
useSetRecoilState,
|
||||
} from 'recoil';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import {
|
||||
type AgentChatThread,
|
||||
useGetChatMessagesQuery,
|
||||
useGetChatThreadsQuery,
|
||||
} from '~/generated-metadata/graphql';
|
||||
|
||||
const setUsageFromThread = (
|
||||
thread: AgentChatThread,
|
||||
setAgentChatUsage: SetterOrUpdater<AgentChatUsageState | null>,
|
||||
) => {
|
||||
const totalTokens = thread.totalInputTokens + thread.totalOutputTokens;
|
||||
const hasUsageData = totalTokens > 0 && isDefined(thread.contextWindowTokens);
|
||||
|
||||
setAgentChatUsage(
|
||||
hasUsageData
|
||||
? {
|
||||
inputTokens: thread.totalInputTokens,
|
||||
outputTokens: thread.totalOutputTokens,
|
||||
totalTokens,
|
||||
contextWindowTokens: thread.contextWindowTokens ?? 0,
|
||||
inputCredits: thread.totalInputCredits,
|
||||
outputCredits: thread.totalOutputCredits,
|
||||
}
|
||||
: null,
|
||||
);
|
||||
};
|
||||
|
||||
export const useAgentChatData = () => {
|
||||
const [currentAIChatThread, setCurrentAIChatThread] = useRecoilState(
|
||||
currentAIChatThreadState,
|
||||
);
|
||||
const setAgentChatUsage = useSetRecoilState(agentChatUsageState);
|
||||
|
||||
const { scrollToBottom } = useAgentChatScrollToBottom();
|
||||
|
||||
@@ -19,7 +50,10 @@ export const useAgentChatData = () => {
|
||||
skip: isDefined(currentAIChatThread),
|
||||
onCompleted: (data) => {
|
||||
if (data.chatThreads.length > 0) {
|
||||
setCurrentAIChatThread(data.chatThreads[0].id);
|
||||
const firstThread = data.chatThreads[0];
|
||||
|
||||
setCurrentAIChatThread(firstThread.id);
|
||||
setUsageFromThread(firstThread, setAgentChatUsage);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
import { agentChatUsageState } from '@/ai/states/agentChatUsageState';
|
||||
import { currentAIChatThreadState } from '@/ai/states/currentAIChatThreadState';
|
||||
import { useOpenAskAIPageInCommandMenu } from '@/command-menu/hooks/useOpenAskAIPageInCommandMenu';
|
||||
import { useRecoilState } from 'recoil';
|
||||
import { useRecoilState, useSetRecoilState } from 'recoil';
|
||||
import { useCreateChatThreadMutation } from '~/generated-metadata/graphql';
|
||||
|
||||
export const useCreateNewAIChatThread = () => {
|
||||
const [, setCurrentAIChatThread] = useRecoilState(currentAIChatThreadState);
|
||||
const setAgentChatUsage = useSetRecoilState(agentChatUsageState);
|
||||
|
||||
const { openAskAIPage } = useOpenAskAIPageInCommandMenu();
|
||||
const [createChatThread] = useCreateChatThreadMutation({
|
||||
onCompleted: (data) => {
|
||||
setCurrentAIChatThread(data.createChatThread.id);
|
||||
setAgentChatUsage(null);
|
||||
openAskAIPage({ resetNavigationStack: false });
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { atom } from 'recoil';
|
||||
|
||||
export type AgentChatUsageState = {
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
totalTokens: number;
|
||||
contextWindowTokens: number;
|
||||
inputCredits: number;
|
||||
outputCredits: number;
|
||||
};
|
||||
|
||||
export const agentChatUsageState = atom<AgentChatUsageState | null>({
|
||||
key: 'agentChatUsageState',
|
||||
default: null,
|
||||
});
|
||||
@@ -5,6 +5,11 @@ describe('groupThreadsByDate', () => {
|
||||
const baseThread: Omit<AgentChatThread, 'createdAt' | 'id'> = {
|
||||
title: 'Test Thread',
|
||||
updatedAt: new Date().toISOString(),
|
||||
totalInputTokens: 0,
|
||||
totalOutputTokens: 0,
|
||||
contextWindowTokens: null,
|
||||
totalInputCredits: 0,
|
||||
totalOutputCredits: 0,
|
||||
};
|
||||
|
||||
const today = new Date();
|
||||
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
import { type MigrationInterface, type QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddUsageColumnsToAgentChatThread1764700000000
|
||||
implements MigrationInterface
|
||||
{
|
||||
name = 'AddUsageColumnsToAgentChatThread1764700000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "core"."agentChatThread" ADD COLUMN "totalInputTokens" integer NOT NULL DEFAULT 0`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "core"."agentChatThread" ADD COLUMN "totalOutputTokens" integer NOT NULL DEFAULT 0`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "core"."agentChatThread" ADD COLUMN "contextWindowTokens" integer`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "core"."agentChatThread" ADD COLUMN "totalInputCredits" bigint NOT NULL DEFAULT 0`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "core"."agentChatThread" ADD COLUMN "totalOutputCredits" bigint NOT NULL DEFAULT 0`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "core"."agentChatThread" DROP COLUMN "totalOutputCredits"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "core"."agentChatThread" DROP COLUMN "totalInputCredits"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "core"."agentChatThread" DROP COLUMN "contextWindowTokens"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "core"."agentChatThread" DROP COLUMN "totalOutputTokens"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "core"."agentChatThread" DROP COLUMN "totalInputTokens"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -1,2 +1,2 @@
|
||||
// Configuration: $0.00001 = 1 credit
|
||||
export const DOLLAR_TO_CREDIT_MULTIPLIER = 1000000; // 1 / 0.000001 = 1000000 credits per dollar
|
||||
export const DOLLAR_TO_CREDIT_MULTIPLIER = 1_000_000; // 1 / 0.000001 = 1 000 000 credits per dollar
|
||||
|
||||
+16
-1
@@ -1,4 +1,4 @@
|
||||
import { Field, ObjectType } from '@nestjs/graphql';
|
||||
import { Field, Int, ObjectType } from '@nestjs/graphql';
|
||||
|
||||
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
|
||||
|
||||
@@ -10,6 +10,21 @@ export class AgentChatThreadDTO {
|
||||
@Field({ nullable: true })
|
||||
title: string;
|
||||
|
||||
@Field(() => Int)
|
||||
totalInputTokens: number;
|
||||
|
||||
@Field(() => Int)
|
||||
totalOutputTokens: number;
|
||||
|
||||
@Field(() => Int, { nullable: true })
|
||||
contextWindowTokens: number | null;
|
||||
|
||||
@Field(() => Int)
|
||||
totalInputCredits: number;
|
||||
|
||||
@Field(() => Int)
|
||||
totalOutputCredits: number;
|
||||
|
||||
@Field()
|
||||
createdAt: Date;
|
||||
|
||||
|
||||
+15
@@ -34,6 +34,21 @@ export class AgentChatThreadEntity {
|
||||
@Column({ nullable: true, type: 'varchar' })
|
||||
title: string;
|
||||
|
||||
@Column({ type: 'int', default: 0 })
|
||||
totalInputTokens: number;
|
||||
|
||||
@Column({ type: 'int', default: 0 })
|
||||
totalOutputTokens: number;
|
||||
|
||||
@Column({ type: 'int', nullable: true })
|
||||
contextWindowTokens: number | null;
|
||||
|
||||
@Column({ type: 'bigint', default: 0 })
|
||||
totalInputCredits: number;
|
||||
|
||||
@Column({ type: 'bigint', default: 0 })
|
||||
totalOutputCredits: number;
|
||||
|
||||
@OneToMany(() => AgentTurnEntity, (turn) => turn.thread)
|
||||
turns: Relation<AgentTurnEntity[]>;
|
||||
|
||||
|
||||
+71
-6
@@ -13,6 +13,7 @@ import {
|
||||
AgentExceptionCode,
|
||||
} from 'src/engine/metadata-modules/ai/ai-agent/agent.exception';
|
||||
import { type BrowsingContextType } from 'src/engine/metadata-modules/ai/ai-agent/types/browsingContext.type';
|
||||
import { convertCentsToBillingCredits } from 'src/engine/metadata-modules/ai/ai-billing/utils/convert-cents-to-billing-credits.util';
|
||||
import { AgentChatThreadEntity } from 'src/engine/metadata-modules/ai/ai-chat/entities/agent-chat-thread.entity';
|
||||
|
||||
import { AgentChatService } from './agent-chat.service';
|
||||
@@ -63,12 +64,13 @@ export class AgentChatStreamingService {
|
||||
try {
|
||||
const uiStream = createUIMessageStream<ExtendedUIMessage>({
|
||||
execute: async ({ writer }) => {
|
||||
const { stream } = await this.chatExecutionService.streamChat({
|
||||
workspace,
|
||||
userWorkspaceId,
|
||||
messages,
|
||||
browsingContext,
|
||||
});
|
||||
const { stream, modelConfig } =
|
||||
await this.chatExecutionService.streamChat({
|
||||
workspace,
|
||||
userWorkspaceId,
|
||||
messages,
|
||||
browsingContext,
|
||||
});
|
||||
|
||||
// Write initial status
|
||||
writer.write({
|
||||
@@ -80,6 +82,14 @@ export class AgentChatStreamingService {
|
||||
},
|
||||
});
|
||||
|
||||
// Track usage from the stream for persisting to thread
|
||||
let streamUsage = {
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
inputCredits: 0,
|
||||
outputCredits: 0,
|
||||
};
|
||||
|
||||
// Merge the AI stream
|
||||
writer.merge(
|
||||
stream.toUIMessageStream({
|
||||
@@ -89,6 +99,48 @@ export class AgentChatStreamingService {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
},
|
||||
sendStart: false,
|
||||
messageMetadata: ({ part }) => {
|
||||
if (part.type === 'finish') {
|
||||
const inputTokens = part.totalUsage?.inputTokens ?? 0;
|
||||
const outputTokens = part.totalUsage?.outputTokens ?? 0;
|
||||
|
||||
const inputCostInCents =
|
||||
(inputTokens / 1000) *
|
||||
modelConfig.inputCostPer1kTokensInCents;
|
||||
const outputCostInCents =
|
||||
(outputTokens / 1000) *
|
||||
modelConfig.outputCostPer1kTokensInCents;
|
||||
|
||||
const inputCredits = Math.round(
|
||||
convertCentsToBillingCredits(inputCostInCents),
|
||||
);
|
||||
const outputCredits = Math.round(
|
||||
convertCentsToBillingCredits(outputCostInCents),
|
||||
);
|
||||
|
||||
streamUsage = {
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
inputCredits,
|
||||
outputCredits,
|
||||
};
|
||||
|
||||
return {
|
||||
createdAt: new Date().toISOString(),
|
||||
usage: {
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
inputCredits,
|
||||
outputCredits,
|
||||
},
|
||||
model: {
|
||||
contextWindowTokens: modelConfig.contextWindowTokens,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return undefined;
|
||||
},
|
||||
onFinish: async ({ responseMessage }) => {
|
||||
if (responseMessage.parts.length === 0) {
|
||||
return;
|
||||
@@ -136,6 +188,19 @@ export class AgentChatStreamingService {
|
||||
uiMessage: responseMessage,
|
||||
turnId: userMessage.turnId,
|
||||
});
|
||||
|
||||
// Update thread usage statistics
|
||||
await this.threadRepository.update(validThreadId, {
|
||||
totalInputTokens: () =>
|
||||
`"totalInputTokens" + ${streamUsage.inputTokens}`,
|
||||
totalOutputTokens: () =>
|
||||
`"totalOutputTokens" + ${streamUsage.outputTokens}`,
|
||||
totalInputCredits: () =>
|
||||
`"totalInputCredits" + ${streamUsage.inputCredits}`,
|
||||
totalOutputCredits: () =>
|
||||
`"totalOutputCredits" + ${streamUsage.outputCredits}`,
|
||||
contextWindowTokens: modelConfig.contextWindowTokens,
|
||||
});
|
||||
} catch (saveError) {
|
||||
this.logger.error(
|
||||
'Failed to save messages:',
|
||||
|
||||
+10
-1
@@ -34,7 +34,10 @@ import { type BrowsingContextType } from 'src/engine/metadata-modules/ai/ai-agen
|
||||
import { repairToolCall } from 'src/engine/metadata-modules/ai/ai-agent/utils/repair-tool-call.util';
|
||||
import { AIBillingService } from 'src/engine/metadata-modules/ai/ai-billing/services/ai-billing.service';
|
||||
import { CHAT_SYSTEM_PROMPTS } from 'src/engine/metadata-modules/ai/ai-chat/constants/chat-system-prompts.const';
|
||||
import { ModelProvider } from 'src/engine/metadata-modules/ai/ai-models/constants/ai-models.const';
|
||||
import {
|
||||
type AIModelConfig,
|
||||
ModelProvider,
|
||||
} from 'src/engine/metadata-modules/ai/ai-models/constants/ai-models.const';
|
||||
import { AI_TELEMETRY_CONFIG } from 'src/engine/metadata-modules/ai/ai-models/constants/ai-telemetry.const';
|
||||
import { AiModelRegistryService } from 'src/engine/metadata-modules/ai/ai-models/services/ai-model-registry.service';
|
||||
|
||||
@@ -48,6 +51,7 @@ export type ChatExecutionOptions = {
|
||||
export type ChatExecutionResult = {
|
||||
stream: ReturnType<typeof streamText>;
|
||||
preloadedTools: string[];
|
||||
modelConfig: AIModelConfig;
|
||||
};
|
||||
|
||||
// Common tools to pre-load for quick access
|
||||
@@ -109,6 +113,10 @@ export class ChatExecutionService {
|
||||
const registeredModel =
|
||||
this.aiModelRegistryService.getDefaultPerformanceModel();
|
||||
|
||||
const modelConfig = this.aiModelRegistryService.getEffectiveModelConfig(
|
||||
registeredModel.modelId,
|
||||
);
|
||||
|
||||
const activeTools: ToolSet = {
|
||||
...preloadedTools,
|
||||
...this.getNativeWebSearchTool(registeredModel.provider),
|
||||
@@ -181,6 +189,7 @@ export class ChatExecutionService {
|
||||
return {
|
||||
stream,
|
||||
preloadedTools: preloadedToolNames,
|
||||
modelConfig,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,10 @@ export type {
|
||||
AgentResponseSchema,
|
||||
} from './types/agent-response-schema.type';
|
||||
export type { DataMessagePart } from './types/DataMessagePart';
|
||||
export type { ExtendedUIMessage } from './types/ExtendedUIMessage';
|
||||
export type {
|
||||
AIChatUsageMetadata,
|
||||
AIChatModelMetadata,
|
||||
ExtendedUIMessage,
|
||||
} from './types/ExtendedUIMessage';
|
||||
export type { ExtendedUIMessagePart } from './types/ExtendedUIMessagePart';
|
||||
export type { ModelConfiguration } from './types/model-configuration.type';
|
||||
|
||||
@@ -1,8 +1,21 @@
|
||||
import { type DataMessagePart } from '@/ai/types/DataMessagePart';
|
||||
import { type UIMessage } from 'ai';
|
||||
|
||||
export type AIChatUsageMetadata = {
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
inputCredits: number;
|
||||
outputCredits: number;
|
||||
};
|
||||
|
||||
export type AIChatModelMetadata = {
|
||||
contextWindowTokens: number;
|
||||
};
|
||||
|
||||
type Metadata = {
|
||||
createdAt: string;
|
||||
usage?: AIChatUsageMetadata;
|
||||
model?: AIChatModelMetadata;
|
||||
};
|
||||
|
||||
export type ExtendedUIMessage = UIMessage<Metadata, DataMessagePart>;
|
||||
|
||||
Reference in New Issue
Block a user