55b1624210
## Summary
Refactored AI chat state management to use component family states
instead of global atoms. This change enables proper state isolation per
chat thread, preventing state leakage between different chat instances.
## Key Changes
- **Converted global atoms to component family states:**
- `agentChatErrorState` → `agentChatErrorComponentFamilyState`
- `agentChatIsStreamingState` →
`agentChatIsStreamingComponentFamilyState`
- `agentChatFirstLiveSeqState` →
`agentChatFirstLiveSeqComponentFamilyState`
- `agentChatHandleEventCallbackState` →
`agentChatHandleEventCallbackComponentFamilyState`
- `agentChatUsageState` → `agentChatUsageComponentFamilyState`
- `currentAIChatThreadTitleState` →
`currentAIChatThreadTitleComponentFamilyState`
- **Updated state access patterns:**
- Introduced `useAtomComponentFamilyStateCallbackState` hook to get
family state callbacks
- Changed from direct atom access to family key-based access using `{
threadId }` as the family key
- Updated all components and hooks to use the new family state patterns
- **Removed instance ID dependency:**
- Eliminated `AGENT_CHAT_INSTANCE_ID` constant usage
- Simplified state access by using thread ID directly as the family key
- **Updated affected components and hooks:**
- `useAgentChatSubscription`: Now creates family state atoms per thread
- `AgentChatMessagesFetchEffect`: Uses family state callbacks for event
handling
- `AgentChatThreadInitializationEffect`: Sets family state values per
thread
- `useAIChatThreadClick`: Updates family states when switching threads
- `AIChatErrorUnderMessageList`, `AIChatLastMessageWithStreamingState`,
`AIChatEmptyState`, `AIChatStandaloneError`, `SendMessageButton`,
`AIChatContextUsageButton`, `SidePanelAskAIInfo`: Updated to use family
state values with thread ID
## Implementation Details
- All new component family states use
`AgentChatComponentInstanceContext` for proper component-level isolation
- Family key structure: `{ threadId: string | null }`
- Removed `disposed` flag logic as it's no longer needed with proper
state isolation
- Updated dependency arrays in hooks to include family callback
functions
https://claude.ai/code/session_01D8syiJtCXu5bEHSr5KYkCt
---------
Co-authored-by: Claude <noreply@anthropic.com>
252 lines
8.3 KiB
TypeScript
252 lines
8.3 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 {
|
|
agentChatUsageComponentFamilyState,
|
|
type AgentChatLastMessageUsage,
|
|
} from '@/ai/states/agentChatUsageComponentFamilyState';
|
|
import { currentAIChatThreadState } from '@/ai/states/currentAIChatThreadState';
|
|
import { SettingsBillingLabelValueItem } from '@/settings/billing/components/internal/SettingsBillingLabelValueItem';
|
|
import { billingState } from '@/client-config/states/billingState';
|
|
import { useAtomComponentFamilyStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentFamilyStateValue';
|
|
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 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 currentAIChatThread = useAtomStateValue(currentAIChatThreadState);
|
|
const agentChatUsage = useAtomComponentFamilyStateValue(
|
|
agentChatUsageComponentFamilyState,
|
|
{ threadId: currentAIChatThread },
|
|
);
|
|
const billing = useAtomStateValue(billingState);
|
|
const isBillingEnabled = billing?.isBillingEnabled ?? false;
|
|
|
|
// Values from the streaming API arrive as display credits (micro-credits / 1000).
|
|
// 1000 display credits = $1. Convert accordingly.
|
|
const formatChatCost = (displayCredits: number): string => {
|
|
if (isBillingEnabled) {
|
|
return `${formatNumber(displayCredits, { decimals: 1 })} credits`;
|
|
}
|
|
const dollars = displayCredits / 1000;
|
|
|
|
return `$${formatNumber(dollars, { decimals: 2 })}`;
|
|
};
|
|
|
|
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={formatChatCost(
|
|
lastMessage.inputCredits + lastMessage.outputCredits,
|
|
)}
|
|
/>
|
|
</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={formatChatCost(totalCredits)}
|
|
/>
|
|
</StyledSection>
|
|
</StyledHoverCard>
|
|
)}
|
|
</StyledContainer>
|
|
);
|
|
};
|