mirror of
https://github.com/n8n-io/n8n.git
synced 2026-08-30 18:01:23 +08:00
feat(editor): Add AI Usage settings page (#20926)
This commit is contained in:
committed by
GitHub
parent
075ffd05f1
commit
ff68b7bd2c
@@ -0,0 +1,6 @@
|
||||
import { z } from 'zod';
|
||||
import { Z } from 'zod-class';
|
||||
|
||||
export class AiUsageSettingsRequestDto extends Z.class({
|
||||
allowSendingParameterValues: z.boolean(),
|
||||
}) {}
|
||||
@@ -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' };
|
||||
|
||||
@@ -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: {},
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
+28
-2
@@ -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>
|
||||
|
||||
+69
@@ -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>
|
||||
+34
-4
@@ -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>
|
||||
|
||||
+8
-1
@@ -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>
|
||||
|
||||
+199
@@ -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>;
|
||||
|
||||
|
||||
+154
-29
@@ -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' });
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user