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:
File diff suppressed because one or more lines are too long
@@ -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>;
|
||||
|
||||
+4
-3
@@ -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,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
+95
-39
@@ -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
|
||||
|
||||
+14
@@ -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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user