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:
Félix Malfait
2025-12-12 13:42:00 +01:00
committed by GitHub
parent ec243e9874
commit 4f91b48470
20 changed files with 570 additions and 22 deletions
@@ -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>
@@ -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();
@@ -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,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
@@ -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;
@@ -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[]>;
@@ -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:',
@@ -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,
};
}
+5 -1
View File
@@ -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>;