From ff68b7bd2c2e07517782b17de77b9eeac6936fc4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Milorad=20FIlipovi=C4=87?= Date: Mon, 2 Feb 2026 09:20:54 +0100 Subject: [PATCH] feat(editor): Add AI Usage settings page (#20926) --- .../dto/ai/ai-usage-settings-request.dto.ts | 6 + packages/@n8n/api-types/src/dto/index.ts | 1 + .../@n8n/api-types/src/frontend-settings.ts | 3 + packages/@n8n/config/src/configs/ai.config.ts | 4 + packages/@n8n/config/test/config.test.ts | 1 + .../scope-information.test.ts.snap | 2 + packages/@n8n/permissions/src/constants.ee.ts | 1 + .../src/roles/scopes/global-scopes.ee.ts | 1 + .../@n8n/permissions/src/scope-information.ts | 4 + .../get-resource-permissions.test.ts | 2 + packages/cli/src/config/schema.ts | 5 + .../__tests__/ai.controller.test.ts | 14 +- packages/cli/src/controllers/ai.controller.ts | 20 +- .../__tests__/ai-usage.service.test.ts | 101 +++++++++ .../__tests__/frontend.service.test.ts | 6 + packages/cli/src/services/ai-usage.service.ts | 43 ++++ packages/cli/src/services/frontend.service.ts | 11 +- .../frontend/@n8n/i18n/src/locales/en.json | 19 ++ .../@n8n/rest-api-client/src/api/ai-usage.ts | 11 + .../@n8n/rest-api-client/src/api/index.ts | 1 + .../editor-ui/src/__tests__/defaults.ts | 3 + .../src/app/composables/useSettingsItems.ts | 9 + .../editor-ui/src/app/constants/navigation.ts | 2 + packages/frontend/editor-ui/src/app/router.ts | 26 +++ .../editor-ui/src/app/stores/rbac.store.ts | 1 + .../src/app/stores/settings.store.ts | 17 ++ .../features/ai/assistant/assistant.store.ts | 73 +++++-- .../features/ai/assistant/assistant.types.ts | 7 + .../features/ai/assistant/builder.utils.ts | 2 +- .../components/Agent/AskAssistantBuild.vue | 30 ++- .../components/AssistantsHub.test.ts | 5 + .../ai/assistant/components/AssistantsHub.vue | 16 +- .../components/Chat/AISettingsButton.vue | 69 ++++++ .../components/Chat/AskAssistantChat.vue | 38 +++- .../Chat/AskAssistantFloatingButton.vue | 9 +- .../ai/assistant/components/HubSwitcher.vue | 29 ++- .../composables/useAIAssistantHelpers.test.ts | 199 ++++++++++++++++++ .../composables/useAIAssistantHelpers.ts | 183 +++++++++++++--- .../ai/assistant/views/SettingsAIView.vue | 168 +++++++++++++++ .../shared/nodeCreator/views/NodeCreation.vue | 11 +- packages/workflow/src/type-guards.ts | 35 ++- 41 files changed, 1114 insertions(+), 74 deletions(-) create mode 100644 packages/@n8n/api-types/src/dto/ai/ai-usage-settings-request.dto.ts create mode 100644 packages/cli/src/services/__tests__/ai-usage.service.test.ts create mode 100644 packages/cli/src/services/ai-usage.service.ts create mode 100644 packages/frontend/@n8n/rest-api-client/src/api/ai-usage.ts create mode 100644 packages/frontend/editor-ui/src/features/ai/assistant/components/Chat/AISettingsButton.vue create mode 100644 packages/frontend/editor-ui/src/features/ai/assistant/views/SettingsAIView.vue diff --git a/packages/@n8n/api-types/src/dto/ai/ai-usage-settings-request.dto.ts b/packages/@n8n/api-types/src/dto/ai/ai-usage-settings-request.dto.ts new file mode 100644 index 00000000000..9e151ada6bb --- /dev/null +++ b/packages/@n8n/api-types/src/dto/ai/ai-usage-settings-request.dto.ts @@ -0,0 +1,6 @@ +import { z } from 'zod'; +import { Z } from 'zod-class'; + +export class AiUsageSettingsRequestDto extends Z.class({ + allowSendingParameterValues: z.boolean(), +}) {} diff --git a/packages/@n8n/api-types/src/dto/index.ts b/packages/@n8n/api-types/src/dto/index.ts index 4be1cbea617..4a9e36ea4be 100644 --- a/packages/@n8n/api-types/src/dto/index.ts +++ b/packages/@n8n/api-types/src/dto/index.ts @@ -6,6 +6,7 @@ export { AiBuilderChatRequestDto } from './ai/ai-build-request.dto'; export { AiApplySuggestionRequestDto } from './ai/ai-apply-suggestion-request.dto'; export { AiFreeCreditsRequestDto } from './ai/ai-free-credits-request.dto'; export { AiSessionRetrievalRequestDto } from './ai/ai-session-retrieval-request.dto'; +export { AiUsageSettingsRequestDto } from './ai/ai-usage-settings-request.dto'; export { AiTruncateMessagesRequestDto } from './ai/ai-truncate-messages-request.dto'; export { BinaryDataQueryDto } from './binary-data/binary-data-query.dto'; diff --git a/packages/@n8n/api-types/src/frontend-settings.ts b/packages/@n8n/api-types/src/frontend-settings.ts index 90b6cc67edf..49db8f5d082 100644 --- a/packages/@n8n/api-types/src/frontend-settings.ts +++ b/packages/@n8n/api-types/src/frontend-settings.ts @@ -207,6 +207,9 @@ export interface FrontendSettings { credits: number; setup: boolean; }; + ai: { + allowSendingParameterValues: boolean; + }; pruning?: { isEnabled: boolean; maxAge: number; diff --git a/packages/@n8n/config/src/configs/ai.config.ts b/packages/@n8n/config/src/configs/ai.config.ts index 741045125e1..dbab6b01b8e 100644 --- a/packages/@n8n/config/src/configs/ai.config.ts +++ b/packages/@n8n/config/src/configs/ai.config.ts @@ -15,6 +15,10 @@ export class AiConfig { @Env('N8N_AI_TIMEOUT_MAX') timeout: number = 3600000; + /** Whether to allow sending actual parameter data to AI services. */ + @Env('N8N_AI_ALLOW_SENDING_PARAMETER_VALUES') + allowSendingParameterValues: boolean = true; + get openAiDefaultHeaders(): Record { // eslint-disable-next-line @typescript-eslint/naming-convention return { 'openai-platform': 'org-qkmJQuJ2WnvoIKMr2UJwIJkZ' }; diff --git a/packages/@n8n/config/test/config.test.ts b/packages/@n8n/config/test/config.test.ts index edaa2b0c73e..9875fb6516c 100644 --- a/packages/@n8n/config/test/config.test.ts +++ b/packages/@n8n/config/test/config.test.ts @@ -411,6 +411,7 @@ describe('GlobalConfig', () => { ai: { enabled: false, timeout: 3600000, + allowSendingParameterValues: true, }, workflowHistoryCompaction: { batchDelayMs: 1_000, diff --git a/packages/@n8n/permissions/src/__tests__/__snapshots__/scope-information.test.ts.snap b/packages/@n8n/permissions/src/__tests__/__snapshots__/scope-information.test.ts.snap index 61cfbef3195..46fa56d8cc3 100644 --- a/packages/@n8n/permissions/src/__tests__/__snapshots__/scope-information.test.ts.snap +++ b/packages/@n8n/permissions/src/__tests__/__snapshots__/scope-information.test.ts.snap @@ -2,6 +2,8 @@ exports[`Scope Information ensure scopes are defined correctly 1`] = ` [ + "aiAssistant:manage", + "aiAssistant:*", "annotationTag:create", "annotationTag:read", "annotationTag:update", diff --git a/packages/@n8n/permissions/src/constants.ee.ts b/packages/@n8n/permissions/src/constants.ee.ts index 23d90405e4e..44ed1c0748a 100644 --- a/packages/@n8n/permissions/src/constants.ee.ts +++ b/packages/@n8n/permissions/src/constants.ee.ts @@ -1,6 +1,7 @@ export const DEFAULT_OPERATIONS = ['create', 'read', 'update', 'delete', 'list'] as const; export const RESOURCES = { + aiAssistant: ['manage'] as const, annotationTag: [...DEFAULT_OPERATIONS] as const, auditLogs: ['manage'] as const, banner: ['dismiss'] as const, diff --git a/packages/@n8n/permissions/src/roles/scopes/global-scopes.ee.ts b/packages/@n8n/permissions/src/roles/scopes/global-scopes.ee.ts index bcbbed48b85..a2e707f9200 100644 --- a/packages/@n8n/permissions/src/roles/scopes/global-scopes.ee.ts +++ b/packages/@n8n/permissions/src/roles/scopes/global-scopes.ee.ts @@ -1,6 +1,7 @@ import type { Scope } from '../../types.ee'; export const GLOBAL_OWNER_SCOPES: Scope[] = [ + 'aiAssistant:manage', 'annotationTag:create', 'annotationTag:read', 'annotationTag:update', diff --git a/packages/@n8n/permissions/src/scope-information.ts b/packages/@n8n/permissions/src/scope-information.ts index 9bbb2ec6c18..832d55f7c02 100644 --- a/packages/@n8n/permissions/src/scope-information.ts +++ b/packages/@n8n/permissions/src/scope-information.ts @@ -24,6 +24,10 @@ export const ALL_SCOPES = buildResourceScopes(); export const ALL_API_KEY_SCOPES = buildApiKeyScopes(); export const scopeInformation: Partial> = { + 'aiAssistant:manage': { + displayName: 'Manage AI Usage', + description: 'Allows managing AI Usage settings.', + }, 'annotationTag:create': { displayName: 'Create Annotation Tag', description: 'Allows creating new annotation tags.', diff --git a/packages/@n8n/permissions/src/utilities/__tests__/get-resource-permissions.test.ts b/packages/@n8n/permissions/src/utilities/__tests__/get-resource-permissions.test.ts index fe1370706b5..043a1257ca5 100644 --- a/packages/@n8n/permissions/src/utilities/__tests__/get-resource-permissions.test.ts +++ b/packages/@n8n/permissions/src/utilities/__tests__/get-resource-permissions.test.ts @@ -6,6 +6,7 @@ import { getResourcePermissions } from '../get-resource-permissions.ee'; describe('permissions', () => { it('getResourcePermissions for empty scopes', () => { expect(getResourcePermissions()).toEqual({ + aiAssistant: {}, annotationTag: {}, auditLogs: {}, banner: {}, @@ -83,6 +84,7 @@ describe('permissions', () => { ]; const permissionRecord: PermissionsRecord = { + aiAssistant: {}, annotationTag: {}, auditLogs: {}, banner: {}, diff --git a/packages/cli/src/config/schema.ts b/packages/cli/src/config/schema.ts index fe2a30ff312..617efab8300 100644 --- a/packages/cli/src/config/schema.ts +++ b/packages/cli/src/config/schema.ts @@ -46,5 +46,10 @@ export const schema = { format: Boolean, default: Container.get(GlobalConfig).ai.enabled, }, + allowSendingParameterValues: { + doc: 'Whether to allow sending actual parameter data to AI services', + format: Boolean, + default: Container.get(GlobalConfig).ai.allowSendingParameterValues, + }, }, }; diff --git a/packages/cli/src/controllers/__tests__/ai.controller.test.ts b/packages/cli/src/controllers/__tests__/ai.controller.test.ts index d0fb101672f..1fff8af01d5 100644 --- a/packages/cli/src/controllers/__tests__/ai.controller.test.ts +++ b/packages/cli/src/controllers/__tests__/ai.controller.test.ts @@ -8,16 +8,24 @@ import type { AuthenticatedRequest } from '@n8n/db'; import type { AiAssistantSDK } from '@n8n_io/ai-assistant-sdk'; import { mock } from 'jest-mock-extended'; +import { AiController, type FlushableResponse } from '../ai.controller'; + import { InternalServerError } from '@/errors/response-errors/internal-server.error'; +import type { AiUsageService } from '@/services/ai-usage.service'; import type { WorkflowBuilderService } from '@/services/ai-workflow-builder.service'; import type { AiService } from '@/services/ai.service'; -import { AiController, type FlushableResponse } from '../ai.controller'; - describe('AiController', () => { const aiService = mock(); const workflowBuilderService = mock(); - const controller = new AiController(aiService, workflowBuilderService, mock(), mock()); + const aiUsageService = mock(); + const controller = new AiController( + aiService, + workflowBuilderService, + mock(), + mock(), + aiUsageService, + ); const request = mock({ user: { id: 'user123' }, diff --git a/packages/cli/src/controllers/ai.controller.ts b/packages/cli/src/controllers/ai.controller.ts index 7ccf52bb8ba..688774ef554 100644 --- a/packages/cli/src/controllers/ai.controller.ts +++ b/packages/cli/src/controllers/ai.controller.ts @@ -7,10 +7,11 @@ import { AiFreeCreditsRequestDto, AiBuilderChatRequestDto, AiSessionRetrievalRequestDto, + AiUsageSettingsRequestDto, AiTruncateMessagesRequestDto, } from '@n8n/api-types'; import { AuthenticatedRequest } from '@n8n/db'; -import { Body, Get, Licensed, Post, RestController } from '@n8n/decorators'; +import { Body, Get, Licensed, Post, RestController, GlobalScope } from '@n8n/decorators'; import { type AiAssistantSDK, APIResponseError } from '@n8n_io/ai-assistant-sdk'; import { Response } from 'express'; import { OPEN_AI_API_CREDENTIAL_TYPE } from 'n8n-workflow'; @@ -22,6 +23,7 @@ import { BadRequestError } from '@/errors/response-errors/bad-request.error'; import { ContentTooLargeError } from '@/errors/response-errors/content-too-large.error'; import { InternalServerError } from '@/errors/response-errors/internal-server.error'; import { TooManyRequestsError } from '@/errors/response-errors/too-many-requests.error'; +import { AiUsageService } from '@/services/ai-usage.service'; import { WorkflowBuilderService } from '@/services/ai-workflow-builder.service'; import { AiService } from '@/services/ai.service'; import { UserService } from '@/services/user.service'; @@ -35,6 +37,7 @@ export class AiController { private readonly workflowBuilderService: WorkflowBuilderService, private readonly credentialsService: CredentialsService, private readonly userService: UserService, + private readonly aiUsageService: AiUsageService, ) {} // Use usesTemplates flag to bypass the send() wrapper which would cause @@ -262,4 +265,19 @@ export class AiController { throw new InternalServerError(e.message, e); } } + + @Post('/usage-settings') + @GlobalScope('aiAssistant:manage') + async updateUsageSettings( + _req: AuthenticatedRequest, + _res: Response, + @Body payload: AiUsageSettingsRequestDto, + ): Promise { + try { + await this.aiUsageService.updateAiUsageSettings(payload.allowSendingParameterValues); + } catch (e) { + assert(e instanceof Error); + throw new InternalServerError(e.message, e); + } + } } diff --git a/packages/cli/src/services/__tests__/ai-usage.service.test.ts b/packages/cli/src/services/__tests__/ai-usage.service.test.ts new file mode 100644 index 00000000000..da1d776a0a3 --- /dev/null +++ b/packages/cli/src/services/__tests__/ai-usage.service.test.ts @@ -0,0 +1,101 @@ +import { mockInstance } from '@n8n/backend-test-utils'; +import type { Settings } from '@n8n/db'; +import { SettingsRepository } from '@n8n/db'; + +import config from '@/config'; +import { AiUsageService } from '@/services/ai-usage.service'; +import { CacheService } from '@/services/cache/cache.service'; + +jest.mock('@/config', () => ({ + set: jest.fn(), +})); + +describe('AiUsageService', () => { + const settingsRepository = mockInstance(SettingsRepository); + const cacheService = mockInstance(CacheService); + + const aiUsageService = new AiUsageService(settingsRepository, cacheService); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('getAiUsageSettings()', () => { + it('should return true when cache has value "true"', async () => { + cacheService.get.mockResolvedValue('true'); + + const result = await aiUsageService.getAiUsageSettings(); + + expect(result).toBe(true); + expect(settingsRepository.findByKey).not.toHaveBeenCalled(); + }); + + it('should return false when cache has value "false"', async () => { + cacheService.get.mockResolvedValue('false'); + + const result = await aiUsageService.getAiUsageSettings(); + + expect(result).toBe(false); + expect(settingsRepository.findByKey).not.toHaveBeenCalled(); + }); + + it('should query database when cache is empty', async () => { + cacheService.get.mockResolvedValue(undefined); + settingsRepository.findByKey.mockResolvedValue({ value: 'true' } as Settings); + + const result = await aiUsageService.getAiUsageSettings(); + + expect(result).toBe(true); + expect(settingsRepository.findByKey).toHaveBeenCalledWith('ai.allowSendingParameterValues'); + expect(cacheService.set).toHaveBeenCalledWith('ai.allowSendingParameterValues', 'true'); + }); + + it('should return false when database has value "false"', async () => { + cacheService.get.mockResolvedValue(undefined); + settingsRepository.findByKey.mockResolvedValue({ value: 'false' } as Settings); + + const result = await aiUsageService.getAiUsageSettings(); + + expect(result).toBe(false); + expect(cacheService.set).toHaveBeenCalledWith('ai.allowSendingParameterValues', 'false'); + }); + + it('should default to true when setting is not found in database', async () => { + cacheService.get.mockResolvedValue(undefined); + settingsRepository.findByKey.mockResolvedValue(null); + + const result = await aiUsageService.getAiUsageSettings(); + + expect(result).toBe(true); + expect(cacheService.set).toHaveBeenCalledWith('ai.allowSendingParameterValues', 'true'); + }); + }); + + describe('updateAiUsageSettings()', () => { + it('should update setting to true', async () => { + settingsRepository.upsert.mockResolvedValue(undefined as never); + + await aiUsageService.updateAiUsageSettings(true); + + expect(settingsRepository.upsert).toHaveBeenCalledWith( + { key: 'ai.allowSendingParameterValues', value: 'true', loadOnStartup: true }, + ['key'], + ); + expect(cacheService.set).toHaveBeenCalledWith('ai.allowSendingParameterValues', 'true'); + expect(config.set).toHaveBeenCalledWith('ai.allowSendingParameterValues', true); + }); + + it('should update setting to false', async () => { + settingsRepository.upsert.mockResolvedValue(undefined as never); + + await aiUsageService.updateAiUsageSettings(false); + + expect(settingsRepository.upsert).toHaveBeenCalledWith( + { key: 'ai.allowSendingParameterValues', value: 'false', loadOnStartup: true }, + ['key'], + ); + expect(cacheService.set).toHaveBeenCalledWith('ai.allowSendingParameterValues', 'false'); + expect(config.set).toHaveBeenCalledWith('ai.allowSendingParameterValues', false); + }); + }); +}); diff --git a/packages/cli/src/services/__tests__/frontend.service.test.ts b/packages/cli/src/services/__tests__/frontend.service.test.ts index f39732c9da2..3d683f4e8a0 100644 --- a/packages/cli/src/services/__tests__/frontend.service.test.ts +++ b/packages/cli/src/services/__tests__/frontend.service.test.ts @@ -12,6 +12,7 @@ import type { LoadNodesAndCredentials } from '@/load-nodes-and-credentials'; import type { MfaService } from '@/mfa/mfa.service'; import { CommunityPackagesConfig } from '@/modules/community-packages/community-packages.config'; import type { PushConfig } from '@/push/push.config'; +import type { AiUsageService } from '@/services/ai-usage.service'; import { FrontendService, type PublicFrontendSettings } from '@/services/frontend.service'; import type { UrlService } from '@/services/url.service'; import type { UserManagementMailer } from '@/user-management/email'; @@ -154,6 +155,10 @@ describe('FrontendService', () => { hasInstanceOwner: jest.fn().mockReturnValue(false), }); + const aiUsageService = mock({ + getAiUsageSettings: jest.fn().mockResolvedValue(true), + }); + const createMockService = () => { Container.set( CommunityPackagesConfig, @@ -180,6 +185,7 @@ describe('FrontendService', () => { moduleRegistry, mfaService, ownershipService, + aiUsageService, ), license, }; diff --git a/packages/cli/src/services/ai-usage.service.ts b/packages/cli/src/services/ai-usage.service.ts new file mode 100644 index 00000000000..1436936a2ac --- /dev/null +++ b/packages/cli/src/services/ai-usage.service.ts @@ -0,0 +1,43 @@ +import { SettingsRepository } from '@n8n/db'; +import { Service } from '@n8n/di'; + +import config from '@/config'; +import { CacheService } from '@/services/cache/cache.service'; + +const KEY = 'ai.allowSendingParameterValues'; + +@Service() +export class AiUsageService { + constructor( + private readonly settingsRepository: SettingsRepository, + private readonly cacheService: CacheService, + ) {} + + /** + * Get the current value of the AI usage (privacy) setting for sending parameter data. + */ + async getAiUsageSettings(): Promise { + const allowSendingParameterValues = await this.cacheService.get(KEY); + + if (allowSendingParameterValues !== undefined) { + return allowSendingParameterValues === 'true'; + } + + const row = await this.settingsRepository.findByKey(KEY); + const allowSending = (row?.value ?? 'true') === 'true'; + await this.cacheService.set(KEY, allowSending.toString()); + return allowSending; + } + + /** + * Update the AI usage setting for sending parameter data. + */ + async updateAiUsageSettings(allowSendingActualData: boolean): Promise { + await this.settingsRepository.upsert( + { key: KEY, value: allowSendingActualData.toString(), loadOnStartup: true }, + ['key'], + ); + await this.cacheService.set(KEY, allowSendingActualData.toString()); + config.set(KEY, allowSendingActualData); + } +} diff --git a/packages/cli/src/services/frontend.service.ts b/packages/cli/src/services/frontend.service.ts index d40bc1d5ab7..282bd3e2d2b 100644 --- a/packages/cli/src/services/frontend.service.ts +++ b/packages/cli/src/services/frontend.service.ts @@ -28,7 +28,7 @@ import { getWorkflowHistoryLicensePruneTime, getWorkflowHistoryPruneTime, } from '@/workflows/workflow-history/workflow-history-helper'; - +import { AiUsageService } from './ai-usage.service'; import { UrlService } from './url.service'; /** @@ -121,6 +121,7 @@ export class FrontendService { private readonly moduleRegistry: ModuleRegistry, private readonly mfaService: MfaService, private readonly ownershipService: OwnershipService, + private readonly aiUsageService: AiUsageService, ) { loadNodesAndCredentials.addPostProcessor(async () => await this.generateTypes()); void this.generateTypes(); @@ -342,6 +343,9 @@ export class FrontendService { credits: 0, setup: false, }, + ai: { + allowSendingParameterValues: true, + }, workflowHistory: { pruneTime: getWorkflowHistoryPruneTime(), licensePruneTime: getWorkflowHistoryLicensePruneTime(), @@ -414,6 +418,11 @@ export class FrontendService { } catch { this.settings.easyAIWorkflowOnboarded = false; } + try { + this.settings.ai.allowSendingParameterValues = await this.aiUsageService.getAiUsageSettings(); + } catch { + this.settings.ai.allowSendingParameterValues = true; + } const isS3Selected = this.binaryDataConfig.mode === 's3'; const isS3Available = this.binaryDataConfig.availableModes.includes('s3'); diff --git a/packages/frontend/@n8n/i18n/src/locales/en.json b/packages/frontend/@n8n/i18n/src/locales/en.json index 900b95180a3..e1630bd5dd7 100644 --- a/packages/frontend/@n8n/i18n/src/locales/en.json +++ b/packages/frontend/@n8n/i18n/src/locales/en.json @@ -232,6 +232,8 @@ "aiAssistant.assistant": "Assistant", "aiAssistant.tabs.ask": "Ask", "aiAssistant.tabs.build": "Build", + "aiAssistant.reducedHelp.chat.notice": "You have opted not to share actual data values. As a result, AI responses will be less accurate and context-aware.", + "aiAssistant.tabs.builder.disabled.tooltip": "AI Builder is disabled because sending data values to AI is turned off. Enable it in AI Usage settings to use AI Builder.", "aiAssistant.builder.mode": "AI Builder", "aiAssistant.builder.placeholder": "Ask n8n to build...", "aiAssistant.builder.assistantPlaceholder": "What would you like to modify or add?", @@ -3767,6 +3769,23 @@ "dataTable.addColumn.systemColumnDescription": "This is a system column, choose a different name", "dataTable.addColumn.alreadyExistsDescription": "Column name already exists, choose a different name", "dataTable.addColumn.testingColumnDescription": "This column is used for testing, choose a different name", + "settings.ai": "AI Usage", + "settings.ai.description.both": "Control what n8n sends when using the AI Assistant and AI Builder", + "settings.ai.description.assistantOnly": "Manage how n8n uses your data to improve our AI features. These settings only apply to AI Assistant Chat feature.", + "settings.ai.description.askAiOnly": "Manage how n8n uses your data to improve our AI features. These settings only apply to Ask AI in the Code node feature.", + "settings.ai.button.tooltip": "AI usage settings", + "settings.ai.allowSendingSchema.label": "Send field names and types (schema)", + "settings.ai.allowSendingSchema.description": "Allow n8n to send key names and types of your data. This helps AI understand your data structure without sending values.", + "settings.ai.allowSendingParameterValues.label": "Send actual data values", + "settings.ai.allowSendingParameterValues.description": "Allow n8n to send real values from your executions which may include sensitive data. Turning this off reduces the accuracy of the AI Assistant and disables AI Workflow Builder.", + "settings.ai.privacyNote.heading": "Privacy Note:", + "settings.ai.privacyNote.content": "Your data is processed securely and is not used to train our models. These settings will help improve AI accuracy and provide context-aware responses. You can learn more here.", + "settings.ai.updated.success": "AI settings updated", + "settings.ai.updated.error": "There was a problem updating AI settings", + "settings.ai.confirm.title": "Stop sending data to AI?", + "settings.ai.confirm.message.builderDisabled": "Disabling data sending will reduce the effectiveness of AI features. Are you sure you want to proceed?", + "settings.ai.confirm.message.builderEnabled": "Disabling data sending will turn off the AI Workflow Builder and reduce the effectiveness of AI features. Are you sure you want to proceed?", + "settings.ai.confirm.confirmButtonText": "Yes, disable", "dataTable.search.dateSearchInfo": "Date searches use UTC format, while the table displays dates in your local timezone", "dataTable.cell.oversized": "Value too large to display", "dataTable.cell.oversized.tooltip": "The value can be modified using the data table node", diff --git a/packages/frontend/@n8n/rest-api-client/src/api/ai-usage.ts b/packages/frontend/@n8n/rest-api-client/src/api/ai-usage.ts new file mode 100644 index 00000000000..11dcdf87edd --- /dev/null +++ b/packages/frontend/@n8n/rest-api-client/src/api/ai-usage.ts @@ -0,0 +1,11 @@ +import type { AiUsageSettingsRequestDto } from '@n8n/api-types'; + +import type { IRestApiContext } from '../types'; +import { makeRestApiRequest } from '../utils'; + +export async function updateAiUsageSettings( + context: IRestApiContext, + data: AiUsageSettingsRequestDto, +): Promise { + return await makeRestApiRequest(context, 'POST', '/ai/usage-settings', data); +} diff --git a/packages/frontend/@n8n/rest-api-client/src/api/index.ts b/packages/frontend/@n8n/rest-api-client/src/api/index.ts index 50b01a9d21d..cac4a663c9d 100644 --- a/packages/frontend/@n8n/rest-api-client/src/api/index.ts +++ b/packages/frontend/@n8n/rest-api-client/src/api/index.ts @@ -1,3 +1,4 @@ +export * from './ai-usage'; export * from './api-keys'; export * from './cloudPlans'; export * from './communityNodes'; diff --git a/packages/frontend/editor-ui/src/__tests__/defaults.ts b/packages/frontend/editor-ui/src/__tests__/defaults.ts index 93b4d158375..3a95cb28655 100644 --- a/packages/frontend/editor-ui/src/__tests__/defaults.ts +++ b/packages/frontend/editor-ui/src/__tests__/defaults.ts @@ -1,6 +1,9 @@ import type { FrontendSettings } from '@n8n/api-types'; export const defaultSettings: FrontendSettings = { + ai: { + allowSendingParameterValues: true, + }, inE2ETests: false, databaseType: 'sqlite', isDocker: false, diff --git a/packages/frontend/editor-ui/src/app/composables/useSettingsItems.ts b/packages/frontend/editor-ui/src/app/composables/useSettingsItems.ts index 9222619ad1f..b5f1df40159 100644 --- a/packages/frontend/editor-ui/src/app/composables/useSettingsItems.ts +++ b/packages/frontend/editor-ui/src/app/composables/useSettingsItems.ts @@ -41,6 +41,15 @@ export function useSettingsItems() { available: canUserAccessRouteByName(VIEWS.USERS_SETTINGS), route: { to: { name: VIEWS.USERS_SETTINGS } }, }, + { + id: 'settings-ai', + icon: 'sparkles', + label: i18n.baseText('settings.ai'), + position: 'top', + available: + settingsStore.isAiAssistantEnabled && canUserAccessRouteByName(VIEWS.AI_SETTINGS), + route: { to: { name: VIEWS.AI_SETTINGS } }, + }, { id: 'settings-project-roles', icon: 'user-round', diff --git a/packages/frontend/editor-ui/src/app/constants/navigation.ts b/packages/frontend/editor-ui/src/app/constants/navigation.ts index 0529b0329e3..08b118b9d5b 100644 --- a/packages/frontend/editor-ui/src/app/constants/navigation.ts +++ b/packages/frontend/editor-ui/src/app/constants/navigation.ts @@ -61,6 +61,8 @@ export const enum VIEWS { SHARED_CREDENTIALS = 'SharedCredentials', ENTITY_NOT_FOUND = 'EntityNotFound', ENTITY_UNAUTHORIZED = 'EntityUnAuthorized', + PRE_BUILT_AGENT_TEMPLATES = 'PreBuiltAgentTemplates', + AI_SETTINGS = 'AISettingsView', OAUTH_CONSENT = 'OAuthConsent', MIGRATION_REPORT = 'MigrationReport', MIGRATION_RULE_REPORT = 'MigrationRuleReport', diff --git a/packages/frontend/editor-ui/src/app/router.ts b/packages/frontend/editor-ui/src/app/router.ts index ec087f6c4e1..6411de3939b 100644 --- a/packages/frontend/editor-ui/src/app/router.ts +++ b/packages/frontend/editor-ui/src/app/router.ts @@ -98,6 +98,7 @@ const TestRunDetailView = async () => await import('@/features/ai/evaluation.ee/views/TestRunDetailView.vue'); const EvaluationRootView = async () => await import('@/features/ai/evaluation.ee/views/EvaluationsRootView.vue'); +const SettingsAIView = async () => await import('@/features/ai/assistant/views/SettingsAIView.vue'); const ResourceCenterView = async () => await import('@/experiments/resourceCenter/views/ResourceCenterView.vue'); const ResourceCenterSectionView = async () => @@ -618,6 +619,31 @@ export const routes: RouteRecordRaw[] = [ }, }, }, + { + path: 'ai', + name: VIEWS.AI_SETTINGS, + component: SettingsAIView, + meta: { + middleware: ['authenticated', 'rbac', 'custom'], + middlewareOptions: { + rbac: { + scope: 'aiAssistant:manage', + }, + custom: () => { + const settingsStore = useSettingsStore(); + return settingsStore.isAiAssistantEnabled || settingsStore.isAskAiEnabled; + }, + }, + telemetry: { + pageCategory: 'settings', + getProperties() { + return { + feature: 'assistant', + }; + }, + }, + }, + }, { path: 'resolvers', name: VIEWS.RESOLVERS, diff --git a/packages/frontend/editor-ui/src/app/stores/rbac.store.ts b/packages/frontend/editor-ui/src/app/stores/rbac.store.ts index 3c0847fbfeb..75440cdfaa4 100644 --- a/packages/frontend/editor-ui/src/app/stores/rbac.store.ts +++ b/packages/frontend/editor-ui/src/app/stores/rbac.store.ts @@ -12,6 +12,7 @@ export const useRBACStore = defineStore(STORES.RBAC, () => { const globalScopes = ref([]); const scopesByProjectId = ref>({}); const scopesByResourceId = ref>>({ + aiAssistant: {}, workflow: {}, tag: {}, annotationTag: {}, diff --git a/packages/frontend/editor-ui/src/app/stores/settings.store.ts b/packages/frontend/editor-ui/src/app/stores/settings.store.ts index 203fb6e4512..06950c56105 100644 --- a/packages/frontend/editor-ui/src/app/stores/settings.store.ts +++ b/packages/frontend/editor-ui/src/app/stores/settings.store.ts @@ -9,6 +9,7 @@ import type { import * as eventsApi from '@n8n/rest-api-client/api/events'; import * as settingsApi from '@n8n/rest-api-client/api/settings'; import * as moduleSettingsApi from '@n8n/rest-api-client/api/module-settings'; +import * as aiUsageApi from '@n8n/rest-api-client/api/ai-usage'; import { testHealthEndpoint } from '@n8n/rest-api-client/api/templates'; import { INSECURE_CONNECTION_WARNING } from '@/app/constants'; import { STORES } from '@n8n/stores'; @@ -115,6 +116,10 @@ export const useSettingsStore = defineStore(STORES.SETTINGS, () => { const aiCreditsQuota = computed(() => settings.value.aiCredits?.credits); + const isAiDataSharingEnabled = computed( + () => settings.value.ai?.allowSendingParameterValues ?? true, + ); + const isSmtpSetup = computed(() => userManagement.value.smtpSetup); const isPersonalizationSurveyEnabled = computed( @@ -328,6 +333,16 @@ export const useSettingsStore = defineStore(STORES.SETTINGS, () => { moduleSettings.value = fetched; }; + const updateAiDataSharingSettings = async (allowSendingParameterValues: boolean) => { + const rootStore = useRootStore(); + await aiUsageApi.updateAiUsageSettings(rootStore.restApiContext, { + allowSendingParameterValues, + }); + if (settings.value.ai) { + settings.value.ai.allowSendingParameterValues = allowSendingParameterValues; + } + }; + return { settings, userManagement, @@ -388,6 +403,7 @@ export const useSettingsStore = defineStore(STORES.SETTINGS, () => { isAiAssistantOrBuilderEnabled, isAiCreditsEnabled, aiCreditsQuota, + isAiDataSharingEnabled, reset, getTimezones, testTemplatesEndpoint, @@ -398,6 +414,7 @@ export const useSettingsStore = defineStore(STORES.SETTINGS, () => { initialize, getModuleSettings, moduleSettings, + updateAiDataSharingSettings, isMFAEnforcementLicensed, isMFAEnforced, activeModules, diff --git a/packages/frontend/editor-ui/src/features/ai/assistant/assistant.store.ts b/packages/frontend/editor-ui/src/features/ai/assistant/assistant.store.ts index f968151bc5c..7d90a75423c 100644 --- a/packages/frontend/editor-ui/src/features/ai/assistant/assistant.store.ts +++ b/packages/frontend/editor-ui/src/features/ai/assistant/assistant.store.ts @@ -28,6 +28,7 @@ import AiUpdatedCodeMessage from '@/app/components/AiUpdatedCodeMessage.vue'; import { useChatPanelStateStore } from './chatPanelState.store'; import { useCredentialsStore } from '@/features/credentials/credentials.store'; import { useAIAssistantHelpers } from '@/features/ai/assistant/composables/useAIAssistantHelpers'; +import { hasPermission } from '@/app/utils/rbac/permissions'; import type { WorkflowState } from '@/app/composables/useWorkflowState'; import { v4 as uuid } from 'uuid'; @@ -109,6 +110,14 @@ export const useAssistantStore = defineStore(STORES.ASSISTANT, () => { EDITABLE_CANVAS_VIEWS.includes(route.name as VIEWS), ); + const canManageAISettings = computed(() => { + return hasPermission(['rbac'], { rbac: { scope: 'aiAssistant:manage' } }); + }); + + const allowSendingParameterValues = computed( + () => settings.settings.ai.allowSendingParameterValues, + ); + function resetAssistantChat() { clearMessages(); currentSessionId.value = undefined; @@ -305,15 +314,22 @@ export const useAssistantStore = defineStore(STORES.ASSISTANT, () => { nodeInfo?: ChatRequest.NodeInfo, ): Promise { if (chatSessionTask.value === 'error') { - return undefined; + return { + aiUsageSettings: { + allowSendingParameterValues: allowSendingParameterValues.value, + }, + }; } const currentView = route.name as VIEWS; const activeNode = workflowsStore.activeNode(); const activeNodeForLLM = activeNode - ? await assistantHelpers.processNodeForAssistant(activeNode, [ - 'position', - 'parameters.notice', - ]) + ? await assistantHelpers.processNodeForAssistant( + activeNode, + ['position', 'parameters.notice'], + { + trimParameterValues: !allowSendingParameterValues.value, + }, + ) : null; const activeModals = uiStore.activeModals; const isCredentialModalActive = activeModals.includes(CREDENTIAL_EDIT_MODAL_KEY); @@ -335,7 +351,11 @@ export const useAssistantStore = defineStore(STORES.ASSISTANT, () => { error: nodeError ? assistantHelpers.simplifyErrorForAssistant(nodeError) : undefined, } : undefined; + return { + aiUsageSettings: { + allowSendingParameterValues: allowSendingParameterValues.value, + }, currentView: { name: currentView, description: assistantHelpers.getCurrentViewDescription(currentView), @@ -355,11 +375,15 @@ export const useAssistantStore = defineStore(STORES.ASSISTANT, () => { } : undefined, currentWorkflow: workflowDataStale.value - ? assistantHelpers.simplifyWorkflowForAssistant(workflowsStore.workflow) + ? await assistantHelpers.simplifyWorkflowForAssistant(workflowsStore.workflow, { + trimParameterValues: !allowSendingParameterValues.value, + }) : undefined, executionData: workflowExecutionDataStale.value && executionResult - ? assistantHelpers.simplifyResultData(executionResult) + ? assistantHelpers.simplifyResultData(executionResult, { + removeParameterValues: !allowSendingParameterValues.value, + }) : undefined, }; } @@ -368,10 +392,18 @@ export const useAssistantStore = defineStore(STORES.ASSISTANT, () => { resetAssistantChat(); chatSessionTask.value = credentialType ? 'credentials' : 'support'; const activeNode = workflowsStore.activeNode() as INode; - const nodeInfo = assistantHelpers.getNodeInfoForAssistant(activeNode); + const nodeInfo = assistantHelpers.getNodeInfoForAssistant(activeNode, { + trimParameterValues: !allowSendingParameterValues.value, + }); // For the initial message, only provide visual context if the task is support const visualContext = - chatSessionTask.value === 'support' ? await getVisualContext(nodeInfo) : undefined; + chatSessionTask.value === 'support' + ? await getVisualContext(nodeInfo) + : { + aiUsageSettings: { + allowSendingParameterValues: allowSendingParameterValues.value, + }, + }; if (nodeInfo.authType && chatSessionTask.value === 'credentials') { userMessage += ` I am using ${nodeInfo.authType.name}.`; @@ -435,6 +467,7 @@ export const useAssistantStore = defineStore(STORES.ASSISTANT, () => { const { authType, nodeInputData, schemas } = assistantHelpers.getNodeInfoForAssistant( context.node, + { trimParameterValues: !allowSendingParameterValues.value }, ); addLoadingAssistantMessage(locale.baseText('aiAssistant.thinkingSteps.analyzingError')); @@ -446,13 +479,21 @@ export const useAssistantStore = defineStore(STORES.ASSISTANT, () => { firstName: usersStore.currentUser?.firstName ?? '', }, error: context.error, - node: await assistantHelpers.processNodeForAssistant(context.node, [ - 'position', - 'parameters.notice', - ]), + node: await assistantHelpers.processNodeForAssistant( + context.node, + ['position', 'parameters.notice'], + { + trimParameterValues: !allowSendingParameterValues.value, + }, + ), nodeInputData, executionSchema: schemas, authType, + context: { + aiUsageSettings: { + allowSendingParameterValues: allowSendingParameterValues.value, + }, + }, }; chatWithAssistant( rootStore.restApiContext, @@ -552,7 +593,9 @@ export const useAssistantStore = defineStore(STORES.ASSISTANT, () => { nodeExecutionStatus.value = 'not_executed'; } const activeNode = workflowsStore.activeNode() as INode; - const nodeInfo = assistantHelpers.getNodeInfoForAssistant(activeNode); + const nodeInfo = assistantHelpers.getNodeInfoForAssistant(activeNode, { + trimParameterValues: !allowSendingParameterValues.value, + }); const userContext = await getVisualContext(nodeInfo); chatWithAssistant( @@ -589,6 +632,7 @@ export const useAssistantStore = defineStore(STORES.ASSISTANT, () => { chat_session_id: currentSessionId.value, message_number: usersMessages.value.length, task: chatSessionTask.value, + allow_sending_parameter_values: allowSendingParameterValues.value, }); } @@ -790,6 +834,7 @@ export const useAssistantStore = defineStore(STORES.ASSISTANT, () => { lastUnread, isSessionEnded, isFloatingButtonShown, + canManageAISettings, onNodeExecution, trackUserOpenedAssistant, isNodeErrorActive, diff --git a/packages/frontend/editor-ui/src/features/ai/assistant/assistant.types.ts b/packages/frontend/editor-ui/src/features/ai/assistant/assistant.types.ts index c8f7e9fa862..95c898c110e 100644 --- a/packages/frontend/editor-ui/src/features/ai/assistant/assistant.types.ts +++ b/packages/frontend/editor-ui/src/features/ai/assistant/assistant.types.ts @@ -11,6 +11,7 @@ import type { ITaskData, } from 'n8n-workflow'; import type { ChatUI } from '@n8n/design-system/types/assistant'; +import type { FrontendSettings } from '@n8n/api-types'; export namespace ChatRequest { export interface NodeExecutionSchema { @@ -58,6 +59,7 @@ export namespace ChatRequest { firstName: string; }; authType?: { name: string; value: string }; + context?: Pick; } export interface InitSupportChat { @@ -124,6 +126,7 @@ export namespace ChatRequest { name: VIEWS; description?: string; }; + aiUsageSettings?: FrontendSettings['ai']; } export type AssistantContext = UserContext & WorkflowContext; @@ -241,6 +244,10 @@ export namespace AskAiRequest { } } +export type AssistantProcessOptions = { + trimParameterValues?: boolean; +}; + // Type guards for ChatRequest messages export function isTextMessage(msg: ChatRequest.MessageResponse): msg is ChatRequest.TextMessage { return 'type' in msg && msg.type === 'message' && 'text' in msg; diff --git a/packages/frontend/editor-ui/src/features/ai/assistant/builder.utils.ts b/packages/frontend/editor-ui/src/features/ai/assistant/builder.utils.ts index 76d630c2658..ff786be579d 100644 --- a/packages/frontend/editor-ui/src/features/ai/assistant/builder.utils.ts +++ b/packages/frontend/editor-ui/src/features/ai/assistant/builder.utils.ts @@ -35,7 +35,7 @@ export async function createBuilderPayload( if (options.workflow) { workflowContext.currentWorkflow = { - ...assistantHelpers.simplifyWorkflowForAssistant(options.workflow), + ...(await assistantHelpers.simplifyWorkflowForAssistant(options.workflow)), id: options.workflow.id, }; } diff --git a/packages/frontend/editor-ui/src/features/ai/assistant/components/Agent/AskAssistantBuild.vue b/packages/frontend/editor-ui/src/features/ai/assistant/components/Agent/AskAssistantBuild.vue index e4bb29636c3..764da4c4456 100644 --- a/packages/frontend/editor-ui/src/features/ai/assistant/components/Agent/AskAssistantBuild.vue +++ b/packages/frontend/editor-ui/src/features/ai/assistant/components/Agent/AskAssistantBuild.vue @@ -6,7 +6,7 @@ import { useHistoryStore } from '@/app/stores/history.store'; import { useCollaborationStore } from '@/features/collaboration/collaboration/collaboration.store'; import { useWorkflowAutosaveStore } from '@/app/stores/workflowAutosave.store'; import { AutoSaveState } from '@/app/constants'; -import { computed, watch, ref } from 'vue'; +import { computed, watch, ref, useSlots } from 'vue'; import { useTelemetry } from '@/app/composables/useTelemetry'; import { useI18n } from '@n8n/i18n'; import { useWorkflowsStore } from '@/app/stores/workflows.store'; @@ -27,6 +27,8 @@ import { useErrorHandler } from '@/app/composables/useErrorHandler'; import type { WorkflowDataUpdate } from '@n8n/rest-api-client/api/workflows'; import { jsonParse } from 'n8n-workflow'; import shuffle from 'lodash/shuffle'; +import AISettingsButton from '@/features/ai/assistant/components/Chat/AISettingsButton.vue'; +import { useAssistantStore } from '@/features/ai/assistant/assistant.store'; import { N8nAskAssistantChat, N8nText } from '@n8n/design-system'; @@ -41,7 +43,9 @@ const historyStore = useHistoryStore(); const collaborationStore = useCollaborationStore(); const workflowAutosaveStore = useWorkflowAutosaveStore(); const telemetry = useTelemetry(); +const slots = useSlots(); const workflowsStore = useWorkflowsStore(); +const assistantStore = useAssistantStore(); const router = useRouter(); const i18n = useI18n(); const route = useRoute(); @@ -75,6 +79,10 @@ watch( }, ); +const showSettingsButton = computed(() => { + return assistantStore.canManageAISettings; +}); + const shouldShowNotificationBanner = computed(() => { return notificationsPermissionsBannerTriggered.value && canPrompt.value; }); @@ -410,7 +418,14 @@ defineExpose({ @show-version="onShowVersion" >