diff --git a/packages/twenty-front/src/modules/ai/components/AiChatEditorSection.tsx b/packages/twenty-front/src/modules/ai/components/AiChatEditorSection.tsx index 11d6408ce4..64777935ca 100644 --- a/packages/twenty-front/src/modules/ai/components/AiChatEditorSection.tsx +++ b/packages/twenty-front/src/modules/ai/components/AiChatEditorSection.tsx @@ -1,7 +1,9 @@ import { styled } from '@linaria/react'; import { EditorContent } from '@tiptap/react'; +import { useLingui } from '@lingui/react/macro'; import { themeCssVariables } from 'twenty-ui/theme-constants'; +import { AiChatBanner } from '@/ai/components/AiChatBanner'; import { AiChatEmptyState } from '@/ai/components/AiChatEmptyState'; import { AIChatNoMoreBillingCreditsBanner } from '@/ai/components/AIChatNoMoreBillingCreditsBanner'; import { AiChatStandaloneError } from '@/ai/components/AiChatStandaloneError'; @@ -14,6 +16,7 @@ import { SendMessageButton } from '@/ai/components/internal/SendMessageButton'; import { useAgentChatModelId } from '@/ai/hooks/useAgentChatModelId'; import { useAiChatEditor } from '@/ai/hooks/useAiChatEditor'; import { useAiModelOptions } from '@/ai/hooks/useAiModelOptions'; +import { useWorkspaceAiModelAvailability } from '@/ai/hooks/useWorkspaceAiModelAvailability'; import { agentChatUserSelectedModelState } from '@/ai/states/agentChatUserSelectedModelState'; import { Select } from '@/ui/input/components/Select'; import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile'; @@ -108,10 +111,13 @@ const StyledRightButtonsContainer = styled.div` `; export const AiChatEditorSection = () => { + const { t } = useLingui(); const isMobile = useIsMobile(); const hasReachedCurrentBillingPeriodCap = useAtomStateValue( hasReachedCurrentBillingPeriodCapSelector, ); + const { enabledModels } = useWorkspaceAiModelAvailability(); + const hasNoEnabledModels = enabledModels.length === 0; const { options, pinnedOption } = useAiModelOptions({ variant: 'pinned-default', }); @@ -140,6 +146,12 @@ export const AiChatEditorSection = () => { + {hasNoEnabledModels && ( + + )} {hasReachedCurrentBillingPeriodCap && ( )} @@ -159,12 +171,16 @@ export const AiChatEditorSection = () => { onChange={setAgentChatUserSelectedModel} options={smartModelOptions} pinnedOption={defaultPinnedOption} + disabled={hasNoEnabledModels} selectSizeVariant="small" showContextualTextInControl={false} withSearchInput dropdownOffset={{ x: 0, y: 8 }} /> - + diff --git a/packages/twenty-front/src/modules/ai/components/__stories__/AiChatBanner.stories.tsx b/packages/twenty-front/src/modules/ai/components/__stories__/AiChatBanner.stories.tsx new file mode 100644 index 0000000000..41681c540e --- /dev/null +++ b/packages/twenty-front/src/modules/ai/components/__stories__/AiChatBanner.stories.tsx @@ -0,0 +1,39 @@ +import { type Meta, type StoryObj } from '@storybook/react-vite'; +import { ComponentDecorator } from 'twenty-ui/testing'; + +import { AiChatBanner } from '@/ai/components/AiChatBanner'; + +const meta: Meta = { + title: 'Modules/AI/AiChatBanner', + component: AiChatBanner, + decorators: [ComponentDecorator], + parameters: { + container: { width: 400 }, + }, +}; + +export default meta; +type Story = StoryObj; + +export const NoEnabledModels: Story = { + args: { + message: 'No AI models are enabled in this workspace.', + variant: 'warning', + }, +}; + +export const Default: Story = { + args: { + message: 'This is an informational message.', + variant: 'default', + }, +}; + +export const WithButton: Story = { + args: { + message: "You've hit your usage limit.", + variant: 'warning', + buttonTitle: 'Upgrade', + buttonOnClick: () => {}, + }, +}; diff --git a/packages/twenty-front/src/modules/ai/components/internal/SendMessageButton.tsx b/packages/twenty-front/src/modules/ai/components/internal/SendMessageButton.tsx index 31f4d016e0..73de0f9900 100644 --- a/packages/twenty-front/src/modules/ai/components/internal/SendMessageButton.tsx +++ b/packages/twenty-front/src/modules/ai/components/internal/SendMessageButton.tsx @@ -11,9 +11,13 @@ import { RoundedIconButton } from 'twenty-ui/input'; type SendMessageButtonProps = { onSend: () => void; + isDisabled?: boolean; }; -export const SendMessageButton = ({ onSend }: SendMessageButtonProps) => { +export const SendMessageButton = ({ + onSend, + isDisabled = false, +}: SendMessageButtonProps) => { const agentChatInputIsEmpty = useAtomStateValue( agentChatInputIsEmptySelector, ); @@ -45,7 +49,7 @@ export const SendMessageButton = ({ onSend }: SendMessageButtonProps) => { Icon={IconArrowUp} size="medium" onClick={onSend} - disabled={agentChatInputIsEmpty || agentChatIsLoading} + disabled={isDisabled || agentChatInputIsEmpty || agentChatIsLoading} /> ); }; diff --git a/packages/twenty-front/src/modules/ai/hooks/useAgentChat.ts b/packages/twenty-front/src/modules/ai/hooks/useAgentChat.ts index e381c5c13b..0249933116 100644 --- a/packages/twenty-front/src/modules/ai/hooks/useAgentChat.ts +++ b/packages/twenty-front/src/modules/ai/hooks/useAgentChat.ts @@ -1,5 +1,6 @@ import { CombinedGraphQLErrors } from '@apollo/client/errors'; import { useApolloClient } from '@apollo/client/react'; +import { t } from '@lingui/core/macro'; import { useStore } from 'jotai'; import { useCallback } from 'react'; import { type ExtendedUIMessage } from 'twenty-shared/ai'; @@ -16,6 +17,7 @@ import { STOP_AGENT_CHAT_STREAM } from '@/ai/graphql/mutations/stopAgentChatStre import { useAgentChatModelId } from '@/ai/hooks/useAgentChatModelId'; import { useGetBrowsingContext } from '@/ai/hooks/useBrowsingContext'; import { useOptimisticallyUnarchiveOnSend } from '@/ai/hooks/useOptimisticallyUnarchiveOnSend'; +import { useWorkspaceAiModelAvailability } from '@/ai/hooks/useWorkspaceAiModelAvailability'; import { AGENT_CHAT_NEW_THREAD_DRAFT_KEY, agentChatDraftsByThreadIdState, @@ -37,6 +39,7 @@ export const useAgentChat = ( ensureThreadIdForSend: () => Promise, ) => { const { modelIdForRequest } = useAgentChatModelId(); + const { enabledModels } = useWorkspaceAiModelAvailability(); const { getBrowsingContext } = useGetBrowsingContext(); const { applyOptimisticUnarchive } = useOptimisticallyUnarchiveOnSend(); const apolloClient = useApolloClient(); @@ -70,6 +73,14 @@ export const useAgentChat = ( return; } + if (enabledModels.length === 0) { + enqueueErrorSnackBar({ + message: t`No AI models are enabled in this workspace.`, + }); + + return; + } + const agentChatSelectedFiles = store.get(agentChatSelectedFilesState.atom); if (agentChatSelectedFiles.length > 0) { @@ -225,6 +236,8 @@ export const useAgentChat = ( setAgentChatUploadedFiles, setAgentChatDraftsByThreadId, modelIdForRequest, + enabledModels, + enqueueErrorSnackBar, setCurrentAiChatThread, apolloClient, applyOptimisticUnarchive, diff --git a/packages/twenty-front/src/pages/settings/ai/components/SettingsAiModelsTab.tsx b/packages/twenty-front/src/pages/settings/ai/components/SettingsAiModelsTab.tsx index 6fe7b9763f..f72b40645a 100644 --- a/packages/twenty-front/src/pages/settings/ai/components/SettingsAiModelsTab.tsx +++ b/packages/twenty-front/src/pages/settings/ai/components/SettingsAiModelsTab.tsx @@ -155,7 +155,7 @@ export const SettingsAiModelsTab = () => {
({ workspaceMemberId, - source: FieldActorSource.MANUAL, + source, name: `${fullNameMetadata.firstName} ${fullNameMetadata.lastName}`, context: {}, }); diff --git a/packages/twenty-server/src/engine/core-modules/tool-provider/services/tool-registry.service.ts b/packages/twenty-server/src/engine/core-modules/tool-provider/services/tool-registry.service.ts index a569212605..cc0d781574 100644 --- a/packages/twenty-server/src/engine/core-modules/tool-provider/services/tool-registry.service.ts +++ b/packages/twenty-server/src/engine/core-modules/tool-provider/services/tool-registry.service.ts @@ -404,6 +404,7 @@ export class ToolRegistryService { roleId: context.roleId, rolePermissionConfig, authContext: context.authContext, + actorContext: context.actorContext, userId: context.userId, userWorkspaceId: context.userWorkspaceId, threadId: context.threadId, diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/services/agent-actor-context.service.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/services/agent-actor-context.service.ts index 7d5169eef9..6ea9a0faa1 100644 --- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/services/agent-actor-context.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/services/agent-actor-context.service.ts @@ -90,13 +90,11 @@ export class AgentActorContextService { ); } - const actorContext: ActorMetadata = { - ...buildCreatedByFromFullNameMetadata({ - fullNameMetadata: workspaceMember.name, - workspaceMemberId: workspaceMember.id, - }), + const actorContext = buildCreatedByFromFullNameMetadata({ + fullNameMetadata: workspaceMember.name, + workspaceMemberId: workspaceMember.id, source: FieldActorSource.AGENT, - }; + }); const userContext: UserContext = { firstName: workspaceMember.name?.firstName ?? '', diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-models/services/ai-model-registry.service.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-models/services/ai-model-registry.service.ts index 62e44f2592..f556f8c59c 100644 --- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-models/services/ai-model-registry.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-models/services/ai-model-registry.service.ts @@ -33,6 +33,7 @@ import { isModelAllowedByWorkspace, type WorkspaceModelAvailabilitySettings, } from 'src/engine/metadata-modules/ai/ai-models/utils/is-model-allowed.util'; +import { workspaceHasEnabledModels } from 'src/engine/metadata-modules/ai/ai-models/utils/workspace-has-enabled-models.util'; export interface RegisteredAiModel { modelId: string; @@ -293,7 +294,7 @@ export class AiModelRegistryService { validateModelAvailability( modelId: string, - workspace: WorkspaceModelAvailabilitySettings, + availabilitySettings: WorkspaceModelAvailabilitySettings, ): void { if (!this.isModelAdminAllowed(modelId)) { throw new AiException( @@ -302,13 +303,17 @@ export class AiModelRegistryService { ); } - if ( - !isModelAllowedByWorkspace( - modelId, - workspace, - this.getRecommendedModelIds(), - ) - ) { + const recommendedModelIds = this.getRecommendedModelIds(); + + const isAvailable = isAutoSelectModelId(modelId) + ? workspaceHasEnabledModels(availabilitySettings, recommendedModelIds) + : isModelAllowedByWorkspace( + modelId, + availabilitySettings, + recommendedModelIds, + ); + + if (!isAvailable) { throw new AiException( 'The selected model is not available in this workspace.', AiExceptionCode.AGENT_EXECUTION_FAILED, diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-models/utils/__tests__/workspace-has-enabled-models.util.spec.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-models/utils/__tests__/workspace-has-enabled-models.util.spec.ts new file mode 100644 index 0000000000..247fd48ea8 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-models/utils/__tests__/workspace-has-enabled-models.util.spec.ts @@ -0,0 +1,52 @@ +import { workspaceHasEnabledModels } from 'src/engine/metadata-modules/ai/ai-models/utils/workspace-has-enabled-models.util'; + +describe('workspaceHasEnabledModels', () => { + describe('when the workspace uses recommended models', () => { + it('returns true when at least one model is recommended', () => { + expect( + workspaceHasEnabledModels( + { useRecommendedModels: true, enabledAiModelIds: [] }, + new Set(['openai/gpt-5.2']), + ), + ).toBe(true); + }); + + it('returns false when no model is recommended', () => { + expect( + workspaceHasEnabledModels( + { useRecommendedModels: true, enabledAiModelIds: [] }, + new Set(), + ), + ).toBe(false); + }); + + it('returns false when no recommended ids are provided', () => { + expect( + workspaceHasEnabledModels({ + useRecommendedModels: true, + enabledAiModelIds: [], + }), + ).toBe(false); + }); + }); + + describe('when the workspace selects models manually', () => { + it('returns true when at least one model is enabled', () => { + expect( + workspaceHasEnabledModels({ + useRecommendedModels: false, + enabledAiModelIds: ['openai/gpt-5.2'], + }), + ).toBe(true); + }); + + it('returns false when every model is disabled', () => { + expect( + workspaceHasEnabledModels({ + useRecommendedModels: false, + enabledAiModelIds: [], + }), + ).toBe(false); + }); + }); +}); diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-models/utils/is-model-allowed.util.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-models/utils/is-model-allowed.util.ts index 3350106786..fec2a326e4 100644 --- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-models/utils/is-model-allowed.util.ts +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-models/utils/is-model-allowed.util.ts @@ -7,16 +7,16 @@ export type WorkspaceModelAvailabilitySettings = { export const isModelAllowedByWorkspace = ( modelId: string, - workspace: WorkspaceModelAvailabilitySettings, + availabilitySettings: WorkspaceModelAvailabilitySettings, recommendedModelIds?: Set, ): boolean => { if (isAutoSelectModelId(modelId)) { return true; } - if (workspace.useRecommendedModels) { + if (availabilitySettings.useRecommendedModels) { return recommendedModelIds?.has(modelId) ?? false; } - return workspace.enabledAiModelIds.includes(modelId); + return availabilitySettings.enabledAiModelIds.includes(modelId); }; diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-models/utils/workspace-has-enabled-models.util.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-models/utils/workspace-has-enabled-models.util.ts new file mode 100644 index 0000000000..9ea6687aaa --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-models/utils/workspace-has-enabled-models.util.ts @@ -0,0 +1,12 @@ +import { type WorkspaceModelAvailabilitySettings } from 'src/engine/metadata-modules/ai/ai-models/utils/is-model-allowed.util'; + +export const workspaceHasEnabledModels = ( + availabilitySettings: WorkspaceModelAvailabilitySettings, + recommendedModelIds?: Set, +): boolean => { + if (availabilitySettings.useRecommendedModels) { + return (recommendedModelIds?.size ?? 0) > 0; + } + + return availabilitySettings.enabledAiModelIds.length > 0; +};