feat(editor): Add AI Usage settings page (#20926)

This commit is contained in:
Milorad FIlipović
2026-02-02 09:20:54 +01:00
committed by GitHub
parent 075ffd05f1
commit ff68b7bd2c
41 changed files with 1114 additions and 74 deletions
@@ -0,0 +1,6 @@
import { z } from 'zod';
import { Z } from 'zod-class';
export class AiUsageSettingsRequestDto extends Z.class({
allowSendingParameterValues: z.boolean(),
}) {}
+1
View File
@@ -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';
@@ -207,6 +207,9 @@ export interface FrontendSettings {
credits: number;
setup: boolean;
};
ai: {
allowSendingParameterValues: boolean;
};
pruning?: {
isEnabled: boolean;
maxAge: number;
@@ -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<string, string> {
// eslint-disable-next-line @typescript-eslint/naming-convention
return { 'openai-platform': 'org-qkmJQuJ2WnvoIKMr2UJwIJkZ' };
+1
View File
@@ -411,6 +411,7 @@ describe('GlobalConfig', () => {
ai: {
enabled: false,
timeout: 3600000,
allowSendingParameterValues: true,
},
workflowHistoryCompaction: {
batchDelayMs: 1_000,
@@ -2,6 +2,8 @@
exports[`Scope Information ensure scopes are defined correctly 1`] = `
[
"aiAssistant:manage",
"aiAssistant:*",
"annotationTag:create",
"annotationTag:read",
"annotationTag:update",
@@ -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,
@@ -1,6 +1,7 @@
import type { Scope } from '../../types.ee';
export const GLOBAL_OWNER_SCOPES: Scope[] = [
'aiAssistant:manage',
'annotationTag:create',
'annotationTag:read',
'annotationTag:update',
@@ -24,6 +24,10 @@ export const ALL_SCOPES = buildResourceScopes();
export const ALL_API_KEY_SCOPES = buildApiKeyScopes();
export const scopeInformation: Partial<Record<Scope, ScopeInformation>> = {
'aiAssistant:manage': {
displayName: 'Manage AI Usage',
description: 'Allows managing AI Usage settings.',
},
'annotationTag:create': {
displayName: 'Create Annotation Tag',
description: 'Allows creating new annotation tags.',
@@ -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: {},
+5
View File
@@ -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,
},
},
};
@@ -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<AiService>();
const workflowBuilderService = mock<WorkflowBuilderService>();
const controller = new AiController(aiService, workflowBuilderService, mock(), mock());
const aiUsageService = mock<AiUsageService>();
const controller = new AiController(
aiService,
workflowBuilderService,
mock(),
mock(),
aiUsageService,
);
const request = mock<AuthenticatedRequest>({
user: { id: 'user123' },
+19 -1
View File
@@ -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<void> {
try {
await this.aiUsageService.updateAiUsageSettings(payload.allowSendingParameterValues);
} catch (e) {
assert(e instanceof Error);
throw new InternalServerError(e.message, e);
}
}
}
@@ -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);
});
});
});
@@ -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<AiUsageService>({
getAiUsageSettings: jest.fn().mockResolvedValue(true),
});
const createMockService = () => {
Container.set(
CommunityPackagesConfig,
@@ -180,6 +185,7 @@ describe('FrontendService', () => {
moduleRegistry,
mfaService,
ownershipService,
aiUsageService,
),
license,
};
@@ -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<boolean> {
const allowSendingParameterValues = await this.cacheService.get<string>(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<void> {
await this.settingsRepository.upsert(
{ key: KEY, value: allowSendingActualData.toString(), loadOnStartup: true },
['key'],
);
await this.cacheService.set(KEY, allowSendingActualData.toString());
config.set(KEY, allowSendingActualData);
}
}
+10 -1
View File
@@ -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');
@@ -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 <b>AI Assistant Chat</b> feature.",
"settings.ai.description.askAiOnly": "Manage how n8n uses your data to improve our AI features. These settings only apply to <b>Ask AI in the Code node</b> 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 <a href=\"{docsLink}\" target=\"_blank\">here</a>.",
"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",
@@ -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<void> {
return await makeRestApiRequest(context, 'POST', '/ai/usage-settings', data);
}
@@ -1,3 +1,4 @@
export * from './ai-usage';
export * from './api-keys';
export * from './cloudPlans';
export * from './communityNodes';
@@ -1,6 +1,9 @@
import type { FrontendSettings } from '@n8n/api-types';
export const defaultSettings: FrontendSettings = {
ai: {
allowSendingParameterValues: true,
},
inE2ETests: false,
databaseType: 'sqlite',
isDocker: false,
@@ -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',
@@ -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',
@@ -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,
@@ -12,6 +12,7 @@ export const useRBACStore = defineStore(STORES.RBAC, () => {
const globalScopes = ref<Scope[]>([]);
const scopesByProjectId = ref<Record<string, Scope[]>>({});
const scopesByResourceId = ref<Record<Resource, Record<string, Scope[]>>>({
aiAssistant: {},
workflow: {},
tag: {},
annotationTag: {},
@@ -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,
@@ -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<ChatRequest.AssistantContext | undefined> {
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,
@@ -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<UserContext, 'aiUsageSettings'>;
}
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;
@@ -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,
};
}
@@ -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"
>
<template #header>
<slot name="header" />
<div :class="{ [$style.header]: true, [$style['with-slot']]: !!slots.header }">
<slot name="header" />
<AISettingsButton
v-if="showSettingsButton"
:show-usability-notice="false"
:disabled="builderStore.streaming"
/>
</div>
</template>
<template #inputHeader>
<Transition name="slide">
@@ -447,6 +462,17 @@ defineExpose({
width: 100%;
}
.header {
display: flex;
justify-content: end;
align-items: center;
flex: 1;
&.with-slot {
justify-content: space-between;
}
}
.topText {
color: var(--color--text);
}
@@ -83,6 +83,11 @@ describe('AssistantsHub', () => {
// Default store states - both modes enabled
settingsStore.isAiAssistantEnabled = true;
settingsStore.settings = {
ai: {
allowSendingParameterValues: true,
},
} as ReturnType<typeof useSettingsStore>['settings'];
builderStore.isAIBuilderEnabled = true;
builderStore.chatMessages = [];
@@ -41,6 +41,10 @@ const askAssistantChatRef = ref<InstanceType<typeof AskAssistantChat>>();
const chatWidth = computed(() => chatPanelStore.width);
const allowSendingParameterValues = computed(
() => settingsStore.settings.ai.allowSendingParameterValues,
);
function onResize(data: { direction: string; x: number; width: number }) {
chatPanelStore.updateWidth(data.width);
}
@@ -138,14 +142,22 @@ onBeforeUnmount(() => {
<div :class="$style.assistantContent">
<AskAssistantBuild v-if="isBuildMode" ref="askAssistantBuildRef" @close="onClose">
<template v-if="canToggleModes" #header>
<HubSwitcher :is-build-mode="isBuildMode" @toggle="toggleAssistantMode" />
<HubSwitcher
:is-build-mode="isBuildMode"
:disabled="!allowSendingParameterValues"
@toggle="toggleAssistantMode"
/>
</template>
</AskAssistantBuild>
<AskAssistantChat v-else ref="askAssistantChatRef" @close="onClose">
<!-- Header switcher is only visible when both modes are available in current view -->
<template v-if="canToggleModes" #header>
<AskModeCoachmark :visible="canShowCoachmark" @dismiss="onDismissCoachmark">
<HubSwitcher :is-build-mode="isBuildMode" @toggle="toggleAssistantMode" />
<HubSwitcher
:is-build-mode="isBuildMode"
:disabled="!allowSendingParameterValues"
@toggle="toggleAssistantMode"
/>
</AskModeCoachmark>
</template>
</AskAssistantChat>
@@ -0,0 +1,69 @@
<script setup lang="ts">
import { useI18n } from '@n8n/i18n';
import { N8nLink, N8nTooltip, N8nIcon, N8nInfoTip } from '@n8n/design-system';
import { VIEWS } from '@/app/constants';
type Props = {
disabled?: boolean;
showUsabilityNotice?: boolean;
};
const props = withDefaults(defineProps<Props>(), {
disabled: false,
showUsabilityNotice: false,
});
const i18n = useI18n();
</script>
<template>
<div :class="$style.container" data-test-id="ai-settings-button">
<N8nInfoTip v-if="props.showUsabilityNotice" theme="warning" type="tooltip">
<span>{{ i18n.baseText('aiAssistant.reducedHelp.chat.notice') }}</span>
</N8nInfoTip>
<N8nTooltip :content="i18n.baseText('settings.ai.button.tooltip')" :disabled="props.disabled">
<N8nLink
:to="props.disabled ? undefined : { name: VIEWS.AI_SETTINGS }"
:aria-label="i18n.baseText('settings.ai.button.tooltip')"
:class="{ [$style.link]: true, [$style.disabled]: props.disabled }"
>
<N8nIcon icon="settings" size="large" color="text-light" />
</N8nLink>
</N8nTooltip>
</div>
</template>
<style lang="scss" module>
.container {
display: flex;
align-items: center;
justify-content: center;
gap: var(--spacing--2xs);
}
.link {
display: flex;
align-items: center;
justify-content: center;
margin-right: var(--spacing--4xs);
svg {
color: var(--color--text);
transition: color 0.2s ease-in-out;
}
&:hover svg {
color: var(--color--text--shade-1);
}
:global(.n8n-text) {
display: flex;
}
&.disabled {
pointer-events: none;
svg {
color: var(--color--text--tint-2);
}
}
}
</style>
@@ -1,8 +1,10 @@
<script lang="ts" setup>
import { useAssistantStore } from '@/features/ai/assistant/assistant.store';
import { useUsersStore } from '@/features/settings/users/users.store';
import { computed, ref } from 'vue';
import { useSettingsStore } from '@/app/stores/settings.store';
import { computed, ref, useSlots } from 'vue';
import { N8nAskAssistantChat } from '@n8n/design-system';
import AISettingsButton from '@/features/ai/assistant/components/Chat/AISettingsButton.vue';
import { useTelemetry } from '@/app/composables/useTelemetry';
import { injectWorkflowState } from '@/app/composables/useWorkflowState';
import { useI18n } from '@n8n/i18n';
@@ -15,11 +17,17 @@ const emit = defineEmits<{
const assistantStore = useAssistantStore();
const workflowState = injectWorkflowState();
const usersStore = useUsersStore();
const settingsStore = useSettingsStore();
const telemetry = useTelemetry();
const slots = useSlots();
const i18n = useI18n();
const n8nChatRef = ref<InstanceType<typeof N8nAskAssistantChat>>();
const allowSendingParameterValues = computed(
() => settingsStore.settings.ai.allowSendingParameterValues,
);
const user = computed(() => ({
firstName: usersStore.currentUser?.firstName ?? '',
lastName: usersStore.currentUser?.lastName ?? '',
@@ -27,6 +35,10 @@ const user = computed(() => ({
const loadingMessage = computed(() => assistantStore.assistantThinkingMessage);
const showSettingsButton = computed(() => {
return assistantStore.canManageAISettings;
});
async function onUserMessage(content: string, quickReplyType?: string, isFeedback = false) {
// If there is no current session running, initialize the support chat session
if (!assistantStore.currentSessionId) {
@@ -72,7 +84,7 @@ defineExpose({
</script>
<template>
<div data-test-id="ask-assistant-chat" tabindex="0" class="wrapper" @keydown.stop>
<div data-test-id="ask-assistant-chat" tabindex="0" :class="$style.wrapper" @keydown.stop>
<N8nAskAssistantChat
ref="n8nChatRef"
:user="user"
@@ -87,7 +99,14 @@ defineExpose({
@code-undo="undoCodeDiff"
>
<template #header>
<slot name="header" />
<div :class="{ [$style.header]: true, [$style['with-slot']]: !!slots.header }">
<slot name="header" />
<AISettingsButton
v-if="showSettingsButton"
:show-usability-notice="!allowSendingParameterValues"
:disabled="assistantStore.streaming"
/>
</div>
</template>
<template #placeholder>
<AskModeEmptyState />
@@ -96,9 +115,20 @@ defineExpose({
</div>
</template>
<style scoped>
<style module lang="scss">
.wrapper {
height: 100%;
width: 100%;
}
.header {
display: flex;
justify-content: end;
align-items: center;
flex: 1;
&.with-slot {
justify-content: space-between;
}
}
</style>
@@ -7,10 +7,12 @@ import { useChatPanelStore } from '../../chatPanel.store';
import { computed } from 'vue';
import { N8nAskAssistantButton, N8nAssistantAvatar, N8nTooltip } from '@n8n/design-system';
import { useSettingsStore } from '@/app/stores/settings.store';
const assistantStore = useAssistantStore();
const builderStore = useBuilderStore();
const chatPanelStore = useChatPanelStore();
const settingsStore = useSettingsStore();
const i18n = useI18n();
const { APP_Z_INDEXES } = useStyles();
@@ -28,8 +30,13 @@ const lastUnread = computed(() => {
return '';
});
const allowSendingParameterValues = computed(
() => settingsStore.settings.ai.allowSendingParameterValues,
);
const onClick = async () => {
if (builderStore.isAIBuilderEnabled) {
// Only start builder mode if it's enabled and parameter values can be sent
if (builderStore.isAIBuilderEnabled && allowSendingParameterValues.value) {
// Toggle with appropriate mode based on current state
if (chatPanelStore.isOpen && chatPanelStore.isBuilderModeActive) {
chatPanelStore.close();
@@ -2,11 +2,16 @@
import { useI18n } from '@n8n/i18n';
import { computed } from 'vue';
import { N8nRadioButtons } from '@n8n/design-system';
import { N8nRadioButtons, N8nTooltip } from '@n8n/design-system';
defineProps<{
type Props = {
isBuildMode: boolean;
}>();
disabled?: boolean;
};
const props = withDefaults(defineProps<Props>(), {
disabled: false,
});
const emit = defineEmits<{
toggle: [value: boolean];
@@ -25,10 +30,16 @@ function toggle(value: boolean) {
</script>
<template>
<N8nRadioButtons
size="small-medium"
:model-value="isBuildMode"
:options="options"
@update:model-value="toggle"
/>
<N8nTooltip
:content="i18n.baseText('aiAssistant.tabs.builder.disabled.tooltip')"
:disabled="!props.disabled"
>
<N8nRadioButtons
size="small"
:model-value="props.isBuildMode"
:options="options"
:disabled="props.disabled"
@update:model-value="toggle"
/>
</N8nTooltip>
</template>
@@ -777,6 +777,205 @@ describe('Simplify assistant payloads', () => {
});
});
describe('processNodeForAssistant - trimParameterValues', () => {
let aiAssistantHelpers: ReturnType<typeof useAIAssistantHelpers>;
beforeEach(() => {
setActivePinia(createTestingPinia());
aiAssistantHelpers = useAIAssistantHelpers();
});
it('Should strip values from set node assignments while preserving schema', async () => {
const node: INode = {
id: 'set-node',
name: 'Set Node',
type: 'n8n-nodes-base.set',
typeVersion: 2,
position: [0, 0],
parameters: {
mode: 'manual',
duplicateItem: false,
assignments: {
assignments: [
{
id: '4c1abbda-52ad-4809-97b6-6a88c421d9a3',
name: 'firstName',
value: 'John',
type: 'string',
},
{
id: 'af2e008d-cde6-45de-b5f1-26576ba463e0',
name: 'lastName',
value: 'Doe',
type: 'string',
},
],
},
includeOtherFields: false,
options: {},
},
};
const processed = await aiAssistantHelpers.processNodeForAssistant(node, [], {
trimParameterValues: true,
});
expect(processed.parameters).toEqual({
mode: '',
duplicateItem: null,
assignments: {
assignments: [
{
id: '4c1abbda-52ad-4809-97b6-6a88c421d9a3',
name: 'firstName',
type: 'string',
},
{
id: 'af2e008d-cde6-45de-b5f1-26576ba463e0',
name: 'lastName',
type: 'string',
},
],
},
includeOtherFields: null,
options: {},
});
});
it('Should sanitize primitive and structured parameter values', async () => {
const resourceMapperValue = {
mappingMode: 'auto',
value: { firstName: 'John' },
matchingColumns: ['firstName'],
schema: [
{
id: 'field1',
displayName: 'First Name',
defaultMatch: true,
required: true,
display: true,
},
],
attemptToConvertTypes: true,
convertFieldsToString: false,
};
const filterValue = {
options: {
caseSensitive: false,
leftValue: 'name',
typeValidation: 'strict',
version: 2,
},
combinator: 'AND',
conditions: [
{
id: 'condition-1',
leftValue: 'email',
operator: {
type: 'string',
operation: 'contains',
},
rightValue: ['@n8n'],
},
],
};
const node: INode = {
id: 'http-node',
name: 'HTTP Node',
type: 'n8n-nodes-base.httpRequest',
typeVersion: 5,
position: [0, 0],
parameters: {
preBuiltAgentsCalloutHttpRequest: '',
curlImport: '',
method: 'GET',
url: '=https://www.api.com/user={{ $json.firstName }}',
authentication: 'none',
provideSslCertificates: false,
sendQuery: true,
nested: {
query: {
field: 'value',
},
},
resourceLocator: {
__rl: true,
mode: 'list',
value: '123',
cachedResultName: 'User',
cachedResultUrl: 'https://example.com',
},
mapper: resourceMapperValue,
filters: filterValue,
options: {},
},
};
const processed = await aiAssistantHelpers.processNodeForAssistant(node, [], {
trimParameterValues: true,
});
expect(processed.parameters).toEqual({
preBuiltAgentsCalloutHttpRequest: '',
curlImport: '',
method: '',
url: '',
authentication: '',
provideSslCertificates: null,
sendQuery: null,
nested: {
query: {
field: '',
},
},
resourceLocator: {
__rl: true,
mode: 'list',
value: '',
},
mapper: {
mappingMode: 'auto',
value: null,
matchingColumns: ['firstName'],
schema: [
{
id: 'field1',
displayName: 'First Name',
defaultMatch: true,
required: true,
display: true,
},
],
attemptToConvertTypes: true,
convertFieldsToString: false,
},
filters: {
options: {
caseSensitive: false,
leftValue: 'name',
typeValidation: 'strict',
version: 2,
},
combinator: 'AND',
conditions: [
{
id: 'condition-1',
leftValue: null,
operator: {
type: 'string',
operation: 'contains',
},
rightValue: [],
},
],
},
options: {},
});
});
});
describe('Trim Payload Size', () => {
let aiAssistantHelpers: ReturnType<typeof useAIAssistantHelpers>;
@@ -1,11 +1,20 @@
import { deepCopy } from 'n8n-workflow';
import {
deepCopy,
isAssignmentCollectionValue,
isFilterValue,
isResourceLocatorValue,
isResourceMapperValue,
} from 'n8n-workflow';
import type {
FilterValue,
IDataObject,
INode,
INodeParameters,
IRunExecutionData,
NodeApiError,
NodeError,
NodeOperationError,
INode,
NodeParameterValueType,
} from 'n8n-workflow';
import { useWorkflowHelpers } from '@/app/composables/useWorkflowHelpers';
import { useNDVStore } from '@/features/ndv/shared/ndv.store';
@@ -15,7 +24,7 @@ import {
getMainAuthField,
getNodeAuthOptions,
} from '@/app/utils/nodeTypesUtils';
import type { ChatRequest } from '../assistant.types';
import type { AssistantProcessOptions, ChatRequest } from '../assistant.types';
import { useWorkflowsStore } from '@/app/stores/workflows.store';
import { useDataSchema } from '@/app/composables/useDataSchema';
import { AI_ASSISTANT_MAX_CONTENT_LENGTH, VIEWS } from '@/app/constants';
@@ -92,6 +101,96 @@ export const useAIAssistantHelpers = () => {
return referencedNodes.size ? Array.from(referencedNodes) : [];
}
/**
* Removes sensitive values from node parameters while preserving structure
* for AI assistant context when allowSendingParameterData is false.
*/
function removeParameterValues(params: INodeParameters): INodeParameters {
const sanitized: INodeParameters = {};
const parameters = params ?? {};
for (const [key, value] of Object.entries(parameters)) {
sanitized[key] = sanitizeParameterValue(value);
}
return sanitized;
}
function sanitizeFilterConditionValue(
value: FilterValue['conditions'][number]['leftValue'],
): FilterValue['conditions'][number]['leftValue'] {
if (Array.isArray(value)) {
return [];
}
return null;
}
function sanitizeParameterValue(value: NodeParameterValueType): NodeParameterValueType {
if (value === null || value === undefined) {
return value;
}
if (Array.isArray(value)) {
return value.map((item) => sanitizeParameterValue(item)) as NodeParameterValueType;
}
if (typeof value === 'string') {
return '';
}
if (typeof value === 'number' || typeof value === 'boolean') {
return null;
}
if (isResourceLocatorValue(value)) {
const {
cachedResultName: _cachedResultName,
cachedResultUrl: _cachedResultUrl,
...rest
} = value;
return {
...rest,
value: '',
};
}
if (isAssignmentCollectionValue(value)) {
return {
assignments:
value.assignments?.map((assignment) => {
const { value: _assignmentValue, ...rest } = assignment;
return { ...rest };
}) ?? [],
};
}
if (isResourceMapperValue(value)) {
return {
...value,
value: null,
};
}
if (isFilterValue(value)) {
return {
...value,
conditions: value.conditions.map((condition) => ({
...condition,
leftValue: sanitizeFilterConditionValue(condition.leftValue),
rightValue: sanitizeFilterConditionValue(condition.rightValue),
})),
};
}
if (typeof value === 'object') {
const sanitizedObject: INodeParameters = {};
for (const [key, childValue] of Object.entries(value)) {
sanitizedObject[key] = sanitizeParameterValue(childValue);
}
return sanitizedObject;
}
return null;
}
/**
* Processes node object before sending it to AI assistant
* - Removes unnecessary properties
@@ -100,26 +199,36 @@ export const useAIAssistantHelpers = () => {
* @param propsToRemove properties to remove from the node object
* @returns processed node
*/
async function processNodeForAssistant(node: INode, propsToRemove: string[]): Promise<INode> {
async function processNodeForAssistant(
node: INode,
propsToRemove: string[],
options?: AssistantProcessOptions,
): Promise<INode> {
// Make a copy of the node object so we don't modify the original
const nodeForLLM = deepCopy(node);
propsToRemove.forEach((key) => {
delete nodeForLLM[key as keyof INode];
});
const resolvedParameters = await workflowHelpers.getNodeParametersWithResolvedExpressions(
nodeForLLM.parameters,
);
nodeForLLM.parameters = resolvedParameters;
if (options?.trimParameterValues) {
nodeForLLM.parameters = removeParameterValues(nodeForLLM.parameters);
} else {
nodeForLLM.parameters = await workflowHelpers.getNodeParametersWithResolvedExpressions(
nodeForLLM.parameters,
);
}
return nodeForLLM;
}
function getNodeInfoForAssistant(node: INode): ChatRequest.NodeInfo {
function getNodeInfoForAssistant(
node: INode,
options?: AssistantProcessOptions,
): ChatRequest.NodeInfo {
if (!node) {
return {};
}
// Get all referenced nodes and their schemas
const referencedNodeNames = getReferencedNodes(node);
const schemas = getNodesSchemas(referencedNodeNames);
const schemas = getNodesSchemas(referencedNodeNames, options?.trimParameterValues);
const nodeType = nodeTypesStore.getNodeType(node.type);
@@ -131,15 +240,18 @@ export const useAIAssistantHelpers = () => {
const availableAuthOptions = getNodeAuthOptions(nodeType);
authType = availableAuthOptions.find((option) => option.value === credentialInUse);
}
let nodeInputData: { inputNodeName?: string; inputData?: IDataObject } | undefined = undefined;
const ndvInput = ndvStore.ndvInputData;
if (isNodeReferencingInputData(node) && ndvInput?.length) {
const inputData = ndvStore.ndvInputData[0].json;
const inputNodeName = ndvStore.input.nodeName;
nodeInputData = {
inputNodeName,
inputData,
};
let nodeInputData: { inputNodeName?: string; inputData?: IDataObject } | undefined = {};
// Only include input data if the node references it and we are allowed to send it
if (!options?.trimParameterValues) {
const ndvInput = ndvStore.ndvInputData;
if (isNodeReferencingInputData(node) && ndvInput?.length) {
const inputData = ndvStore.ndvInputData[0].json;
const inputNodeName = ndvStore.input.nodeName;
nodeInputData = {
inputNodeName,
inputData,
};
}
}
return {
authType,
@@ -228,16 +340,16 @@ export const useAIAssistantHelpers = () => {
**/
function simplifyResultData(
data: IRunExecutionData['resultData'],
options: { compact?: boolean } = {},
options: { compact?: boolean; removeParameterValues?: boolean } = {},
): ChatRequest.ExecutionResultData {
const { compact = false } = options;
const { compact = false, removeParameterValues = false } = options;
const simplifiedResultData: ChatRequest.ExecutionResultData = {
runData: {},
};
// Handle optional error
// Handle optional error (can contain node parameter values, so we omit it if removeParameterValues is true)
if (data.error) {
simplifiedResultData.error = data.error;
simplifiedResultData.error = removeParameterValues ? undefined : data.error;
}
// Early return if runData is not present
@@ -285,12 +397,25 @@ export const useAIAssistantHelpers = () => {
return simplifiedResultData;
}
const simplifyWorkflowForAssistant = (workflow: IWorkflowDb): Partial<IWorkflowDb> => ({
name: workflow.name,
active: workflow.active,
connections: workflow.connections,
nodes: workflow.nodes,
});
const simplifyWorkflowForAssistant = async (
workflow: IWorkflowDb,
options?: AssistantProcessOptions,
): Promise<Partial<IWorkflowDb>> => {
let nodes: INode[] = workflow.nodes;
if (options?.trimParameterValues) {
nodes = await Promise.all(
workflow.nodes.map(
async (node) => await processNodeForAssistant(node, [], { trimParameterValues: true }),
),
);
}
return {
name: workflow.name,
active: workflow.active,
connections: workflow.connections,
nodes,
};
};
/**
* Extract all expressions from workflow nodes and resolve them to their values.
@@ -0,0 +1,168 @@
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue';
import { N8nHeading, N8nCheckbox, N8nText } from '@n8n/design-system';
import { useI18n } from '@n8n/i18n';
import { useToast } from '@/app/composables/useToast';
import { useDocumentTitle } from '@/app/composables/useDocumentTitle';
import { useAssistantStore } from '@/features/ai/assistant/assistant.store';
import { useSettingsStore } from '@/app/stores/settings.store';
import { useMessage } from '@/app/composables/useMessage';
import { MODAL_CONFIRM } from '@/app/constants';
import { useTelemetry } from '@/app/composables/useTelemetry';
const i18n = useI18n();
const toast = useToast();
const documentTitle = useDocumentTitle();
const message = useMessage();
const telemetry = useTelemetry();
const assistantStore = useAssistantStore();
const settingsStore = useSettingsStore();
const allowSendingSchema = ref(true);
const isAssistantEnabled = computed(() => assistantStore.isAssistantEnabled);
const isBuilderEnabled = computed(() => settingsStore.isAiBuilderEnabled);
const isAskAiEnabled = computed(() => settingsStore.isAskAiEnabled);
const allowSendingParameterValues = computed(() => settingsStore.isAiDataSharingEnabled);
const aiSettingsDescription = computed(() => {
if (isAssistantEnabled.value && isAskAiEnabled.value) {
return i18n.baseText('settings.ai.description.both');
} else if (isAssistantEnabled.value) {
return i18n.baseText('settings.ai.description.assistantOnly');
} else if (isAskAiEnabled.value) {
return i18n.baseText('settings.ai.description.askAiOnly');
}
// Fallback to 'both' if neither is enabled (edge case)
return i18n.baseText('settings.ai.description.both');
});
const confirmationMessage = computed(() => {
if (isBuilderEnabled.value) {
return i18n.baseText('settings.ai.confirm.message.builderEnabled');
}
return i18n.baseText('settings.ai.confirm.message.builderDisabled');
});
const onallowSendingParameterValuesChange = async (newValue: boolean | string | number) => {
if (typeof newValue !== 'boolean') return;
if (!newValue) {
const promptResponse = await message.confirm(confirmationMessage.value, {
title: i18n.baseText('settings.ai.confirm.title'),
confirmButtonText: i18n.baseText('settings.ai.confirm.confirmButtonText'),
cancelButtonText: i18n.baseText('generic.cancel'),
});
if (promptResponse !== MODAL_CONFIRM) {
return;
}
}
try {
await settingsStore.updateAiDataSharingSettings(newValue);
toast.showMessage({
title: i18n.baseText('settings.ai.updated.success'),
type: 'success',
});
telemetry.track('User changed AI Usage settings', {
allow_sending_parameter_values: newValue,
});
} catch (error) {
toast.showError(error, i18n.baseText('settings.ai.updated.error'));
}
};
onMounted(async () => {
documentTitle.set(i18n.baseText('settings.ai'));
});
</script>
<template>
<div :class="$style.container" data-test-id="ai">
<div :class="$style.header">
<N8nHeading size="2xlarge">{{ i18n.baseText('settings.ai') }}</N8nHeading>
<N8nText v-n8n-html="aiSettingsDescription" size="small" color="text-light" />
</div>
<div :class="$style.content">
<div :class="$style.checkboxContainer">
<N8nCheckbox
v-model="allowSendingSchema"
:disabled="true"
:label="i18n.baseText('settings.ai.allowSendingSchema.label')"
/>
<N8nText :class="$style.checkboxDescription" color="text-base">
{{ i18n.baseText('settings.ai.allowSendingSchema.description') }}
</N8nText>
</div>
<div :class="$style.checkboxContainer">
<N8nCheckbox
:model-value="allowSendingParameterValues"
:label="i18n.baseText('settings.ai.allowSendingParameterValues.label')"
@update:model-value="onallowSendingParameterValuesChange"
/>
<N8nText :class="$style.checkboxDescription" color="text-base">
{{ i18n.baseText('settings.ai.allowSendingParameterValues.description') }}
</N8nText>
</div>
</div>
<div :class="$style.privacyNote">
<N8nText :bold="true">{{ i18n.baseText('settings.ai.privacyNote.heading') }}</N8nText>
<N8nText
v-n8n-html="
i18n.baseText('settings.ai.privacyNote.content', {
interpolate: { docsLink: 'https://docs.n8n.io/manage-cloud/ai-assistant' },
})
"
color="text-base"
/>
</div>
</div>
</template>
<style lang="scss" module>
.container {
display: flex;
flex-direction: column;
gap: var(--spacing--xl);
}
.header {
display: flex;
flex-direction: column;
gap: var(--spacing--2xs);
}
.content {
display: flex;
flex-direction: column;
gap: var(--spacing--2xs);
}
.checkboxContainer {
display: flex;
flex-direction: column;
border: var(--border-width) var(--border-style) var(--color--info--tint-1);
border-radius: var(--radius);
padding: var(--spacing--md) var(--spacing--md) var(--spacing--xs);
label {
font-weight: var(--font-weight--bold);
padding-bottom: var(--spacing--5xs);
}
.checkboxDescription {
padding: var(--spacing--2xs) var(--spacing--xl);
}
.notice {
margin-left: var(--spacing--xl);
margin-top: var(--spacing--2xs);
}
}
.privacyNote {
span + span {
margin-left: var(--spacing--4xs);
}
}
</style>
@@ -1,6 +1,6 @@
<script setup lang="ts">
/* eslint-disable vue/no-multiple-template-root */
import { defineAsyncComponent, nextTick } from 'vue';
import { computed, defineAsyncComponent, nextTick } from 'vue';
import { getMidCanvasPosition } from '@/app/utils/nodeViewUtils';
import {
DEFAULT_STICKY_HEIGHT,
@@ -24,6 +24,7 @@ import { useBuilderStore } from '@/features/ai/assistant/builder.store';
import { useChatPanelStore } from '@/features/ai/assistant/chatPanel.store';
import { N8nAssistantIcon, N8nButton, N8nIconButton, N8nTooltip } from '@n8n/design-system';
import { useSettingsStore } from '@/app/stores/settings.store';
type Props = {
nodeViewScale: number;
@@ -53,9 +54,14 @@ const telemetry = useTelemetry();
const assistantStore = useAssistantStore();
const builderStore = useBuilderStore();
const chatPanelStore = useChatPanelStore();
const settingsStore = useSettingsStore();
const { getAddedNodesAndConnections } = useActions();
const allowSendingParameterValues = computed(
() => settingsStore.settings.ai.allowSendingParameterValues,
);
function openNodeCreator() {
emit('toggleNodeCreator', {
source: NODE_CREATOR_OPEN_SOURCES.ADD_NODE_BUTTON,
@@ -102,7 +108,8 @@ function toggleFocusPanel() {
}
async function onAskAssistantButtonClick() {
if (builderStore.isAIBuilderEnabled) {
// Only start builder mode if it's enabled and parameter values can be sent
if (builderStore.isAIBuilderEnabled && allowSendingParameterValues.value) {
await chatPanelStore.toggle({ mode: 'builder' });
} else {
await chatPanelStore.toggle({ mode: 'assistant' });
+30 -5
View File
@@ -1,11 +1,13 @@
import {
type INodeProperties,
type INodePropertyOptions,
type INodePropertyCollection,
type INodeParameterResourceLocator,
type ResourceMapperValue,
type AssignmentCollectionValue,
type AssignmentValue,
type FilterValue,
type INodeParameterResourceLocator,
type INodeProperties,
type INodePropertyCollection,
type INodePropertyOptions,
type NodeConnectionType,
type ResourceMapperValue,
nodeConnectionTypes,
type IBinaryData,
} from './interfaces';
@@ -65,6 +67,29 @@ export const isResourceMapperValue = (value: unknown): value is ResourceMapperVa
);
};
export const isAssignmentValue = (value: unknown): value is AssignmentValue => {
return (
typeof value === 'object' &&
value !== null &&
'id' in value &&
typeof value.id === 'string' &&
'name' in value &&
typeof value.name === 'string' &&
'value' in value &&
(!('type' in value) || typeof value.type === 'string')
);
};
export const isAssignmentCollectionValue = (value: unknown): value is AssignmentCollectionValue => {
return (
typeof value === 'object' &&
value !== null &&
'assignments' in value &&
Array.isArray(value.assignments) &&
value.assignments.every(isAssignmentValue)
);
};
export const isFilterValue = (value: unknown): value is FilterValue => {
return (
typeof value === 'object' && value !== null && 'conditions' in value && 'combinator' in value