feat: improve AI chat - system prompt, tool output, context window display (#17769)

⚠️ **AI-generated PR — not ready for review** ⚠️

cc @FelixMalfait

---

## Changes

### System prompt improvements
- Explicit skill-before-tools workflow to prevent the model from calling
tools without loading the matching skill first
- Data efficiency guidance (default small limits, use filters)
- Pluralized `load_skill` → `load_skills` for consistency with
`load_tools`

### Token usage reduction
- Output serialization layer: strips null/undefined/empty values from
tool results
- Lowered default `find_*` limit from 100 → 10, max from 1000 → 100

### System object tool generation
- System objects (calendar events, messages, etc.) now generate AI tools
- Only workflow-related and favorite-related objects are excluded

### Context window display fix
- **Bug**: UI compared cumulative tokens (sum of all turns) against
single-request context window → showed 100% after a few turns
- **Fix**: Track `conversationSize` (last step's `inputTokens`) which
represents the actual conversation history size sent to the model
- New `conversationSize` column on thread entity with migration

### Workspace AI instructions
- Support for custom workspace-level AI instructions

---------

Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
This commit is contained in:
Félix Malfait
2026-02-09 14:26:02 +01:00
committed by GitHub
parent 6c7c389785
commit 3216b634a3
105 changed files with 3245 additions and 1714 deletions
File diff suppressed because one or more lines are too long
+19 -2
View File
@@ -21,6 +21,19 @@ export type Scalars = {
Upload: any;
};
export type AiSystemPromptPreview = {
__typename?: 'AISystemPromptPreview';
estimatedTokenCount: Scalars['Int'];
sections: Array<AiSystemPromptSection>;
};
export type AiSystemPromptSection = {
__typename?: 'AISystemPromptSection';
content: Scalars['String'];
estimatedTokenCount: Scalars['Int'];
title: Scalars['String'];
};
export type ActivateWorkspaceInput = {
displayName?: InputMaybe<Scalars['String']>;
};
@@ -76,12 +89,13 @@ export type Agent = {
export type AgentChatThread = {
__typename?: 'AgentChatThread';
contextWindowTokens?: Maybe<Scalars['Int']>;
conversationSize: Scalars['Int'];
createdAt: Scalars['DateTime'];
id: Scalars['UUID'];
title?: Maybe<Scalars['String']>;
totalInputCredits: Scalars['Int'];
totalInputCredits: Scalars['Float'];
totalInputTokens: Scalars['Int'];
totalOutputCredits: Scalars['Int'];
totalOutputCredits: Scalars['Float'];
totalOutputTokens: Scalars['Int'];
updatedAt: Scalars['DateTime'];
};
@@ -2039,6 +2053,7 @@ export enum MessageChannelVisibility {
export enum ModelProvider {
ANTHROPIC = 'ANTHROPIC',
GROQ = 'GROQ',
NONE = 'NONE',
OPENAI = 'OPENAI',
OPENAI_COMPATIBLE = 'OPENAI_COMPATIBLE',
@@ -4759,6 +4774,7 @@ export type UpdateWorkflowVersionStepInput = {
};
export type UpdateWorkspaceInput = {
aiAdditionalInstructions?: InputMaybe<Scalars['String']>;
allowImpersonation?: InputMaybe<Scalars['Boolean']>;
customDomain?: InputMaybe<Scalars['String']>;
defaultRoleId?: InputMaybe<Scalars['UUID']>;
@@ -5146,6 +5162,7 @@ export type WorkflowVersionStepChanges = {
export type Workspace = {
__typename?: 'Workspace';
activationStatus: WorkspaceActivationStatus;
aiAdditionalInstructions?: Maybe<Scalars['String']>;
allowImpersonation: Scalars['Boolean'];
billingEntitlements: Array<BillingEntitlement>;
billingSubscriptions: Array<BillingSubscription>;
@@ -30,6 +30,7 @@ const StyledEditorContainer = styled.div<{
color: ${({ theme, readonly }) =>
readonly ? theme.font.color.light : theme.font.color.primary};
font-family: ${({ theme }) => theme.font.family};
font-size: ${({ theme }) => theme.font.size.sm};
font-weight: ${({ theme }) => theme.font.weight.regular};
border: none !important;
@@ -54,15 +55,15 @@ const StyledEditorContainer = styled.div<{
}
h1 {
font-size: 32px;
font-size: 1.5em;
}
h2 {
font-size: 24px;
font-size: 1.3em;
}
h3 {
font-size: 16px;
font-size: 1.1em;
}
li {
@@ -84,17 +84,18 @@ export const AIChatThreadGroup = ({
const handleThreadClick = (thread: AgentChatThread) => {
setCurrentAIChatThread(thread.id);
const totalTokens = thread.totalInputTokens + thread.totalOutputTokens;
const hasUsageData =
totalTokens > 0 && isDefined(thread.contextWindowTokens);
(thread.conversationSize ?? 0) > 0 &&
isDefined(thread.contextWindowTokens);
setAgentChatUsage(
hasUsageData
? {
lastMessage: null,
conversationSize: thread.conversationSize ?? 0,
contextWindowTokens: thread.contextWindowTokens ?? 0,
inputTokens: thread.totalInputTokens,
outputTokens: thread.totalOutputTokens,
totalTokens,
contextWindowTokens: thread.contextWindowTokens ?? 0,
inputCredits: thread.totalInputCredits,
outputCredits: thread.totalOutputCredits,
}
@@ -9,7 +9,10 @@ import { AnimatedExpandableContainer } from 'twenty-ui/layout';
import { CodeExecutionDisplay } from '@/ai/components/CodeExecutionDisplay';
import { ShimmeringText } from '@/ai/components/ShimmeringText';
import { getToolIcon } from '@/ai/utils/getToolIcon';
import { getToolDisplayMessage } from '@/ai/utils/getWebSearchToolDisplayMessage';
import {
getToolDisplayMessage,
resolveToolInput,
} from '@/ai/utils/getToolDisplayMessage';
import { useLingui } from '@lingui/react/macro';
import { type ToolUIPart } from 'ai';
import { isDefined } from 'twenty-shared/utils';
@@ -132,12 +135,10 @@ export const ToolStepRenderer = ({ toolPart }: { toolPart: ToolUIPart }) => {
const [activeTab, setActiveTab] = useState<TabType>('output');
const { input, output, type, errorText } = toolPart;
const toolName = type.split('-')[1];
const rawToolName = type.split('-')[1];
const toolInput =
isDefined(input) && typeof input === 'object' && 'input' in input
? input.input
: input;
const { resolvedInput: toolInput, resolvedToolName: toolName } =
resolveToolInput(input, rawToolName);
const hasError = isDefined(errorText);
const isExpandable = isDefined(output) || hasError;
@@ -175,7 +176,7 @@ export const ToolStepRenderer = ({ toolPart }: { toolPart: ToolUIPart }) => {
<StyledLoadingContainer>
<ShimmeringText>
<StyledDisplayMessage>
{getToolDisplayMessage(input, toolName, false)}
{getToolDisplayMessage(input, rawToolName, false)}
</StyledDisplayMessage>
</ShimmeringText>
</StyledLoadingContainer>
@@ -188,19 +189,32 @@ export const ToolStepRenderer = ({ toolPart }: { toolPart: ToolUIPart }) => {
);
}
// For execute_tool, the actual result is nested inside output.result
const unwrappedOutput =
rawToolName === 'execute_tool' &&
isDefined(output) &&
typeof output === 'object' &&
'result' in output
? (output as { result: unknown }).result
: output;
const displayMessage = hasError
? t`Tool execution failed`
: output &&
typeof output === 'object' &&
'message' in output &&
typeof output.message === 'string'
? output.message
: getToolDisplayMessage(input, toolName, true);
: rawToolName === 'learn_tools' || rawToolName === 'execute_tool'
? getToolDisplayMessage(input, rawToolName, true)
: unwrappedOutput &&
typeof unwrappedOutput === 'object' &&
'message' in unwrappedOutput &&
typeof unwrappedOutput.message === 'string'
? unwrappedOutput.message
: getToolDisplayMessage(input, rawToolName, true);
const result =
output && typeof output === 'object' && 'result' in output
? (output as { result: string }).result
: output;
unwrappedOutput &&
typeof unwrappedOutput === 'object' &&
'result' in unwrappedOutput
? (unwrappedOutput as { result: string }).result
: unwrappedOutput;
const ToolIcon = getToolIcon(toolName);
@@ -82,8 +82,10 @@ print("Chart saved successfully!")`,
usage: {
inputTokens: 1250,
outputTokens: 890,
cachedInputTokens: 0,
inputCredits: 12,
outputCredits: 8,
conversationSize: 1250,
},
},
};
@@ -1,12 +1,17 @@
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { t } from '@lingui/core/macro';
import { useLingui } from '@lingui/react/macro';
import { useState } from 'react';
import { useRecoilValue } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
import { ProgressBar } from 'twenty-ui/feedback';
import { ContextUsageProgressRing } from '@/ai/components/internal/ContextUsageProgressRing';
import { agentChatUsageState } from '@/ai/states/agentChatUsageState';
import {
agentChatUsageState,
type AgentChatLastMessageUsage,
} from '@/ai/states/agentChatUsageState';
const StyledContainer = styled.div`
position: relative;
@@ -41,14 +46,14 @@ const StyledHoverCard = styled.div`
border: 1px solid ${({ theme }) => theme.border.color.medium};
border-radius: ${({ theme }) => theme.border.radius.md};
box-shadow: ${({ theme }) => theme.boxShadow.strong};
min-width: 240px;
min-width: 280px;
position: absolute;
right: 0;
bottom: calc(100% + 8px);
z-index: ${({ theme }) => theme.lastLayerZIndex};
`;
const StyledHeader = styled.div`
const StyledSection = styled.div`
display: flex;
flex-direction: column;
gap: ${({ theme }) => theme.spacing(2)};
@@ -61,14 +66,6 @@ const StyledRow = styled.div`
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};
@@ -79,15 +76,16 @@ const StyledValue = styled.span`
font-size: ${({ theme }) => theme.font.size.sm};
`;
const StyledFooter = styled.div`
align-items: center;
background: ${({ theme }) => theme.background.secondary};
const StyledSectionTitle = styled.span`
color: ${({ theme }) => theme.font.color.primary};
font-size: ${({ theme }) => theme.font.size.xs};
font-weight: ${({ theme }) => theme.font.weight.semiBold};
text-transform: uppercase;
letter-spacing: 0.5px;
`;
const StyledDivider = styled.div`
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 => {
@@ -103,6 +101,31 @@ const formatTokenCount = (count: number): string => {
return count.toString();
};
const formatCredits = (credits: number): string => {
// Credits are already in display units from the API (internal / 1000)
// Show up to 1 decimal for fractional values, none for whole numbers
if (Number.isInteger(credits)) {
return credits.toLocaleString();
}
return credits.toLocaleString(undefined, {
minimumFractionDigits: 0,
maximumFractionDigits: 1,
});
};
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 theme = useTheme();
@@ -121,14 +144,14 @@ export const AIChatContextUsageButton = () => {
}
const percentage = Math.min(
(agentChatUsage.totalTokens / agentChatUsage.contextWindowTokens) * 100,
(agentChatUsage.conversationSize / agentChatUsage.contextWindowTokens) *
100,
100,
);
const formattedPercentage = percentage.toFixed(1);
const totalCredits =
agentChatUsage.inputCredits + agentChatUsage.outputCredits;
const inputCredits = agentChatUsage.inputCredits.toLocaleString();
const outputCredits = agentChatUsage.outputCredits.toLocaleString();
const lastMessage = agentChatUsage.lastMessage;
return (
<StyledContainer
@@ -142,12 +165,13 @@ export const AIChatContextUsageButton = () => {
{isHovered && (
<StyledHoverCard>
<StyledHeader>
<StyledSection>
<StyledRow>
<StyledPercentage>{formattedPercentage}%</StyledPercentage>
<StyledValue>
{formatTokenCount(agentChatUsage.totalTokens)} /{' '}
{formatTokenCount(agentChatUsage.contextWindowTokens)}
{formatTokenCount(agentChatUsage.conversationSize)} /{' '}
{formatTokenCount(agentChatUsage.contextWindowTokens)}{' '}
{t`tokens`}
</StyledValue>
</StyledRow>
<ProgressBar
@@ -162,29 +186,61 @@ export const AIChatContextUsageButton = () => {
backgroundColor={theme.background.quaternary}
withBorderRadius
/>
</StyledHeader>
</StyledSection>
<StyledBody>
{isDefined(lastMessage) && (
<>
<StyledDivider />
<StyledSection>
<StyledSectionTitle>{t`Last message`}</StyledSectionTitle>
<StyledRow>
<StyledLabel>{t`Input tokens`}</StyledLabel>
<StyledValue>
{formatTokenCount(lastMessage.inputTokens)}
{getCachedLabel(lastMessage)}
</StyledValue>
</StyledRow>
<StyledRow>
<StyledLabel>{t`Output tokens`}</StyledLabel>
<StyledValue>
{formatTokenCount(lastMessage.outputTokens)}
</StyledValue>
</StyledRow>
<StyledRow>
<StyledLabel>{t`Cost`}</StyledLabel>
<StyledValue>
{formatCredits(
lastMessage.inputCredits + lastMessage.outputCredits,
)}{' '}
{t`credits`}
</StyledValue>
</StyledRow>
</StyledSection>
</>
)}
<StyledDivider />
<StyledSection>
<StyledSectionTitle>{t`Conversation`}</StyledSectionTitle>
<StyledRow>
<StyledLabel>{t`Input`}</StyledLabel>
<StyledLabel>{t`Input tokens`}</StyledLabel>
<StyledValue>
{formatTokenCount(agentChatUsage.inputTokens)} {' '}
{t`${inputCredits} credits`}
{formatTokenCount(agentChatUsage.inputTokens)}
</StyledValue>
</StyledRow>
<StyledRow>
<StyledLabel>{t`Output`}</StyledLabel>
<StyledLabel>{t`Output tokens`}</StyledLabel>
<StyledValue>
{formatTokenCount(agentChatUsage.outputTokens)} {' '}
{t`${outputCredits} credits`}
{formatTokenCount(agentChatUsage.outputTokens)}
</StyledValue>
</StyledRow>
</StyledBody>
<StyledFooter>
<StyledLabel>{t`Total credits`}</StyledLabel>
<StyledPercentage>{totalCredits.toLocaleString()}</StyledPercentage>
</StyledFooter>
<StyledRow>
<StyledLabel>{t`Total cost`}</StyledLabel>
<StyledValue>
{formatCredits(totalCredits)} {t`credits`}
</StyledValue>
</StyledRow>
</StyledSection>
</StyledHoverCard>
)}
</StyledContainer>
@@ -8,6 +8,7 @@ export const GET_CHAT_THREADS = gql`
totalInputTokens
totalOutputTokens
contextWindowTokens
conversationSize
totalInputCredits
totalOutputCredits
createdAt
@@ -119,8 +119,10 @@ export const useAgentChat = (uiMessages: ExtendedUIMessage[]) => {
type UsageMetadata = {
inputTokens: number;
outputTokens: number;
cachedInputTokens: number;
inputCredits: number;
outputCredits: number;
conversationSize: number;
};
type ModelMetadata = {
contextWindowTokens: number;
@@ -133,11 +135,17 @@ export const useAgentChat = (uiMessages: ExtendedUIMessage[]) => {
if (isDefined(usage) && isDefined(model)) {
setAgentChatUsage((prev) => ({
lastMessage: {
inputTokens: usage.inputTokens,
outputTokens: usage.outputTokens,
cachedInputTokens: usage.cachedInputTokens,
inputCredits: usage.inputCredits,
outputCredits: usage.outputCredits,
},
conversationSize: usage.conversationSize,
contextWindowTokens: model.contextWindowTokens,
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,
}));
@@ -23,16 +23,17 @@ const setUsageFromThread = (
thread: AgentChatThread,
setAgentChatUsage: SetterOrUpdater<AgentChatUsageState | null>,
) => {
const totalTokens = thread.totalInputTokens + thread.totalOutputTokens;
const hasUsageData = totalTokens > 0 && isDefined(thread.contextWindowTokens);
const hasUsageData =
(thread.conversationSize ?? 0) > 0 && isDefined(thread.contextWindowTokens);
setAgentChatUsage(
hasUsageData
? {
lastMessage: null,
conversationSize: thread.conversationSize ?? 0,
contextWindowTokens: thread.contextWindowTokens ?? 0,
inputTokens: thread.totalInputTokens,
outputTokens: thread.totalOutputTokens,
totalTokens,
contextWindowTokens: thread.contextWindowTokens ?? 0,
inputCredits: thread.totalInputCredits,
outputCredits: thread.totalOutputCredits,
}
@@ -1,10 +1,19 @@
import { atom } from 'recoil';
export type AgentChatUsageState = {
export type AgentChatLastMessageUsage = {
inputTokens: number;
outputTokens: number;
totalTokens: number;
cachedInputTokens: number;
inputCredits: number;
outputCredits: number;
};
export type AgentChatUsageState = {
lastMessage: AgentChatLastMessageUsage | null;
conversationSize: number;
contextWindowTokens: number;
inputTokens: number;
outputTokens: number;
inputCredits: number;
outputCredits: number;
};
@@ -8,6 +8,7 @@ describe('groupThreadsByDate', () => {
totalInputTokens: 0,
totalOutputTokens: 0,
contextWindowTokens: null,
conversationSize: 0,
totalInputCredits: 0,
totalOutputCredits: 0,
};
@@ -0,0 +1,99 @@
import { type ToolInput } from '@/ai/types/ToolInput';
import { isDefined } from 'twenty-shared/utils';
const extractSearchQuery = (input: ToolInput): string => {
if (!input) {
return '';
}
if (
typeof input === 'object' &&
'query' in input &&
typeof input.query === 'string'
) {
return input.query;
}
if (
typeof input === 'object' &&
'action' in input &&
isDefined(input.action) &&
typeof input.action === 'object' &&
'query' in input.action &&
typeof input.action.query === 'string'
) {
return input.action.query;
}
return '';
};
const extractLoadingMessage = (input: ToolInput): string => {
if (
isDefined(input) &&
typeof input === 'object' &&
'loadingMessage' in input &&
typeof input.loadingMessage === 'string'
) {
return input.loadingMessage;
}
return 'Processing...';
};
export const resolveToolInput = (
input: ToolInput,
toolName: string,
): { resolvedInput: ToolInput; resolvedToolName: string } => {
if (
toolName === 'execute_tool' &&
isDefined(input) &&
typeof input === 'object' &&
'toolName' in input &&
'arguments' in input
) {
return {
resolvedInput: input.arguments as ToolInput,
resolvedToolName: String(input.toolName),
};
}
return { resolvedInput: input, resolvedToolName: toolName };
};
const extractLearnToolNames = (input: ToolInput): string => {
if (
isDefined(input) &&
typeof input === 'object' &&
'toolNames' in input &&
Array.isArray(input.toolNames)
) {
return input.toolNames.join(', ');
}
return '';
};
export const getToolDisplayMessage = (
input: ToolInput,
toolName: string,
isFinished?: boolean,
): string => {
const { resolvedInput, resolvedToolName } = resolveToolInput(input, toolName);
if (resolvedToolName === 'web_search') {
const query = extractSearchQuery(resolvedInput);
const action = isFinished ? 'Searched' : 'Searching';
return query ? `${action} the web for '${query}'` : `${action} the web`;
}
if (resolvedToolName === 'learn_tools') {
const names = extractLearnToolNames(resolvedInput);
const action = isFinished ? 'Learned' : 'Learning';
return names ? `${action} ${names}` : `${action} tools...`;
}
return extractLoadingMessage(resolvedInput);
};
@@ -1,6 +1,16 @@
import { IconDatabase, IconMail, IconTool, IconWorld } from 'twenty-ui/display';
import {
IconBook2,
IconDatabase,
IconMail,
IconTool,
IconWorld,
} from 'twenty-ui/display';
const TOOL_ICON_MAPPINGS = [
{
keywords: ['learn_tools'],
icon: IconBook2,
},
{
keywords: ['email'],
icon: IconMail,
@@ -1,56 +0,0 @@
import { type ToolInput } from '@/ai/types/ToolInput';
import { isDefined } from 'twenty-shared/utils';
const extractSearchQuery = (input: ToolInput): string => {
if (!input) {
return '';
}
if (
typeof input === 'object' &&
'query' in input &&
typeof input.query === 'string'
) {
return input.query;
}
if (
typeof input === 'object' &&
'action' in input &&
isDefined(input.action) &&
typeof input.action === 'object' &&
'query' in input.action &&
typeof input.action.query === 'string'
) {
return input.action.query;
}
return '';
};
const extractLoadingMessage = (input: ToolInput): string => {
if (
isDefined(input) &&
typeof input === 'object' &&
'loadingMessage' in input &&
typeof input.loadingMessage === 'string'
) {
return input.loadingMessage;
}
return 'Processing...';
};
export const getToolDisplayMessage = (
input: ToolInput,
toolName: string,
isFinished?: boolean,
): string => {
if (toolName === 'web_search') {
const query = extractSearchQuery(input);
const action = isFinished ? 'Searched' : 'Searching';
return query ? `${action} the web for '${query}'` : `${action} the web`;
}
return extractLoadingMessage(input);
};
@@ -185,6 +185,12 @@ const SettingsSkillForm = lazy(() =>
})),
);
const SettingsAIPrompts = lazy(() =>
import('~/pages/settings/ai/SettingsAIPrompts').then((module) => ({
default: module.SettingsAIPrompts,
})),
);
const SettingsWorkspaceMembers = lazy(() =>
import('~/pages/settings/members/SettingsWorkspaceMembers').then(
(module) => ({
@@ -442,6 +448,7 @@ export const SettingsRoutes = ({ isAdminPageEnabled }: SettingsRoutesProps) => (
element={<SettingsApiWebhooks />}
/>
<Route path={SettingsPath.AI} element={<SettingsAI />} />
<Route path={SettingsPath.AIPrompts} element={<SettingsAIPrompts />} />
<Route
path={SettingsPath.AINewAgent}
element={<SettingsAgentForm mode="create" />}
@@ -36,6 +36,7 @@ export type CurrentWorkspace = Pick<
| 'eventLogRetentionDays'
| 'fastModel'
| 'smartModel'
| 'aiAdditionalInstructions'
| 'editableProfileFields'
> & {
defaultRole?: Omit<Role, 'workspaceMembers' | 'agents' | 'apiKeys'> | null;
@@ -88,6 +88,7 @@ export const USER_QUERY_FRAGMENT = gql`
}
fastModel
smartModel
aiAdditionalInstructions
isTwoFactorAuthenticationEnforced
trashRetentionDays
eventLogRetentionDays
@@ -0,0 +1,14 @@
import { gql } from '@apollo/client';
export const GET_AI_SYSTEM_PROMPT_PREVIEW = gql`
query GetAISystemPromptPreview {
getAISystemPromptPreview {
sections {
title
content
estimatedTokenCount
}
estimatedTokenCount
}
}
`;
@@ -1,3 +1,7 @@
import styled from '@emotion/styled';
import { Link } from 'react-router-dom';
import { SettingsOptionCardContentButton } from '@/settings/components/SettingsOptions/SettingsOptionCardContentButton';
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer';
import { TabList } from '@/ui/layout/tab-list/components/TabList';
@@ -7,13 +11,25 @@ import { SettingsPath } from 'twenty-shared/types';
import { getSettingsPath } from 'twenty-shared/utils';
import { t } from '@lingui/core/macro';
import { IconSettings, IconSparkles, IconTool } from 'twenty-ui/display';
import {
H2Title,
IconFileText,
IconSettings,
IconSparkles,
IconTool,
} from 'twenty-ui/display';
import { Button } from 'twenty-ui/input';
import { Card, Section } from 'twenty-ui/layout';
import { SettingsAIMCP } from './components/SettingsAIMCP';
import { SettingsAIRouterSettings } from './components/SettingsAIRouterSettings';
import { SettingsSkillsTable } from './components/SettingsSkillsTable';
import { SettingsToolsTable } from './components/SettingsToolsTable';
import { SETTINGS_AI_TABS } from './constants/SettingsAiTabs';
const StyledLink = styled(Link)`
text-decoration: none;
`;
export const SettingsAI = () => {
const activeTabId = useRecoilComponentValue(
activeTabIdComponentState,
@@ -63,6 +79,28 @@ export const SettingsAI = () => {
{isSettingsTab && (
<>
<SettingsAIRouterSettings />
<Section>
<H2Title
title={t`System Prompt`}
description={t`View and customize AI instructions`}
/>
<Card rounded>
<SettingsOptionCardContentButton
Icon={IconFileText}
title={t`System Prompt`}
description={t`View the AI system prompt and add custom instructions`}
Button={
<StyledLink to={getSettingsPath(SettingsPath.AIPrompts)}>
<Button
title={t`Configure`}
variant="secondary"
size="small"
/>
</StyledLink>
}
/>
</Card>
</Section>
<SettingsAIMCP />
</>
)}
@@ -0,0 +1,263 @@
import { ApolloError } from '@apollo/client';
import styled from '@emotion/styled';
import { useDebouncedCallback } from 'use-debounce';
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { FormAdvancedTextFieldInput } from '@/object-record/record-field/ui/form-types/components/FormAdvancedTextFieldInput';
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer';
import { t } from '@lingui/core/macro';
import { useState } from 'react';
import { useRecoilState, useRecoilValue } from 'recoil';
import { SettingsPath } from 'twenty-shared/types';
import { getSettingsPath, isDefined } from 'twenty-shared/utils';
import { H2Title } from 'twenty-ui/display';
import { Section } from 'twenty-ui/layout';
import {
useGetAiSystemPromptPreviewQuery,
useUpdateWorkspaceMutation,
} from '~/generated-metadata/graphql';
const StyledFormContainer = styled.div`
display: flex;
flex-direction: column;
gap: ${({ theme }) => theme.spacing(4)};
`;
const StyledTokenBadge = styled.span`
background: ${({ theme }) => theme.background.transparent.light};
border-radius: ${({ theme }) => theme.border.radius.sm};
color: ${({ theme }) => theme.font.color.tertiary};
font-size: ${({ theme }) => theme.font.size.xs};
padding: ${({ theme }) => theme.spacing(0.5)}
${({ theme }) => theme.spacing(1.5)};
white-space: nowrap;
`;
export const SettingsAIPrompts = () => {
const { enqueueErrorSnackBar } = useSnackBar();
const [currentWorkspace, setCurrentWorkspace] = useRecoilState(
currentWorkspaceState,
);
const currentWorkspaceMember = useRecoilValue(currentWorkspaceMemberState);
const [updateWorkspace] = useUpdateWorkspaceMutation();
const { data: previewData, loading: previewLoading } =
useGetAiSystemPromptPreviewQuery();
const [workspaceInstructions, setWorkspaceInstructions] = useState(
currentWorkspace?.aiAdditionalInstructions ?? '',
);
const [originalInstructions, setOriginalInstructions] = useState(
currentWorkspace?.aiAdditionalInstructions ?? '',
);
const handleWorkspaceInstructionsInit = () => {
if (currentWorkspace?.aiAdditionalInstructions !== undefined) {
setWorkspaceInstructions(currentWorkspace.aiAdditionalInstructions ?? '');
setOriginalInstructions(currentWorkspace.aiAdditionalInstructions ?? '');
}
};
if (
currentWorkspace?.aiAdditionalInstructions !== undefined &&
originalInstructions === '' &&
currentWorkspace.aiAdditionalInstructions !== null &&
currentWorkspace.aiAdditionalInstructions !== originalInstructions
) {
handleWorkspaceInstructionsInit();
}
const autoSave = useDebouncedCallback(async (newValue: string) => {
if (!currentWorkspace?.id || newValue === originalInstructions) {
return;
}
try {
setCurrentWorkspace({
...currentWorkspace,
aiAdditionalInstructions: newValue || null,
});
await updateWorkspace({
variables: {
input: {
aiAdditionalInstructions: newValue || null,
},
},
});
setOriginalInstructions(newValue);
} catch (error) {
setCurrentWorkspace({
...currentWorkspace,
aiAdditionalInstructions: originalInstructions || null,
});
if (error instanceof ApolloError) {
enqueueErrorSnackBar({
apolloError: error,
});
} else {
enqueueErrorSnackBar({
message: t`Failed to save workspace instructions`,
});
}
}
}, 1000);
const handleWorkspaceInstructionsChange = (value: string) => {
setWorkspaceInstructions(value);
autoSave(value);
};
const preview = previewData?.getAISystemPromptPreview;
const sections = preview?.sections ?? [];
const buildUserContextPreview = (): string => {
if (!isDefined(currentWorkspaceMember)) {
return '';
}
const parts = [
`**${t`User`}:** ${currentWorkspaceMember.name.firstName} ${currentWorkspaceMember.name.lastName}`.trim(),
`**${t`Locale`}:** ${currentWorkspaceMember.locale ?? 'en'}`,
];
if (isDefined(currentWorkspaceMember.timeZone)) {
parts.push(`**${t`Timezone`}:** ${currentWorkspaceMember.timeZone}`);
}
return parts.join('\n\n');
};
const userContextPreview = buildUserContextPreview();
const promptSections = sections.filter(
(section) =>
section.title !== 'Workspace Instructions' &&
section.title !== 'User Context',
);
const formatTokenCount = (count: number): string => {
if (count >= 1000) {
const kTokens = (count / 1000).toFixed(1);
return t`~${kTokens}k tokens`;
}
return t`~${count} tokens`;
};
const totalTokenCount = isDefined(preview)
? formatTokenCount(preview.estimatedTokenCount)
: '';
const pageTitle = isDefined(preview)
? t`System Prompt (${totalTokenCount})`
: t`System Prompt`;
return (
<SubMenuTopBarContainer
title={pageTitle}
links={[
{
children: t`Workspace`,
href: getSettingsPath(SettingsPath.Workspace),
},
{ children: t`AI`, href: getSettingsPath(SettingsPath.AI) },
{ children: t`System Prompt` },
]}
>
<SettingsPageContainer>
{promptSections.map((section) => (
<Section key={section.title}>
<H2Title
title={section.title}
description={t`Read-only — managed by Twenty`}
adornment={
<StyledTokenBadge>
{formatTokenCount(section.estimatedTokenCount)}
</StyledTokenBadge>
}
/>
<StyledFormContainer>
<FormAdvancedTextFieldInput
key={
previewLoading ? `loading-${section.title}` : section.title
}
label={section.title}
readonly={true}
defaultValue={section.content}
contentType="markdown"
onChange={() => {}}
enableFullScreen={true}
fullScreenBreadcrumbs={[
{
children: t`System Prompt`,
href: '#',
},
{
children: section.title,
},
]}
minHeight={120}
maxWidth={700}
/>
</StyledFormContainer>
</Section>
))}
<Section>
<H2Title
title={t`Workspace Instructions`}
description={t`Add custom instructions specific to your workspace (appended to system prompt)`}
/>
<StyledFormContainer>
<FormAdvancedTextFieldInput
key={originalInstructions}
label={t`Additional Instructions`}
readonly={false}
defaultValue={workspaceInstructions}
contentType="markdown"
onChange={handleWorkspaceInstructionsChange}
enableFullScreen={true}
fullScreenBreadcrumbs={[
{
children: t`System Prompt`,
href: '#',
},
{
children: t`Workspace Instructions`,
},
]}
placeholder={t`E.g., "We are a B2B SaaS company. Always use formal language..."`}
minHeight={150}
maxWidth={700}
/>
</StyledFormContainer>
</Section>
<Section>
<H2Title
title={t`User Context`}
description={t`Information about the current user (auto-generated and included in each request)`}
/>
<StyledFormContainer>
<FormAdvancedTextFieldInput
label={t`User Information`}
readonly={true}
defaultValue={userContextPreview}
contentType="markdown"
onChange={() => {}}
enableFullScreen={false}
minHeight={80}
maxWidth={700}
/>
</StyledFormContainer>
</Section>
</SettingsPageContainer>
</SubMenuTopBarContainer>
);
};