feat: Add Ask assistant behind feature flag (#9995)

Co-authored-by: Ricardo Espinoza <ricardo@n8n.io>
Co-authored-by: Milorad Filipovic <milorad@n8n.io>
This commit is contained in:
Mutasem Aldmour
2024-08-14 14:59:11 +02:00
committed by GitHub
parent e4c88e75f9
commit 5ed2a77740
70 changed files with 3414 additions and 60 deletions

View File

@@ -90,6 +90,7 @@
"@n8n/n8n-nodes-langchain": "workspace:*",
"@n8n/permissions": "workspace:*",
"@n8n/typeorm": "0.3.20-10",
"@n8n_io/ai-assistant-sdk": "1.9.4",
"@n8n_io/license-sdk": "2.13.0",
"@oclif/core": "4.0.7",
"@rudderstack/rudder-sdk-node": "2.0.7",

View File

@@ -249,6 +249,10 @@ export class License {
return this.isFeatureEnabled(LICENSE_FEATURES.SAML);
}
isAiAssistantEnabled() {
return this.isFeatureEnabled(LICENSE_FEATURES.AI_ASSISTANT);
}
isAdvancedExecutionFiltersEnabled() {
return this.isFeatureEnabled(LICENSE_FEATURES.ADVANCED_EXECUTION_FILTERS);
}

View File

@@ -41,6 +41,7 @@ import '@/controllers/activeWorkflows.controller';
import '@/controllers/auth.controller';
import '@/controllers/binaryData.controller';
import '@/controllers/curl.controller';
import '@/controllers/aiAssistant.controller';
import '@/controllers/dynamicNodeParameters.controller';
import '@/controllers/invitation.controller';
import '@/controllers/me.controller';

View File

@@ -569,6 +569,15 @@ export const schema = {
},
},
aiAssistant: {
baseUrl: {
doc: 'Base URL of the AI assistant service',
format: String,
default: '',
env: 'N8N_AI_ASSISTANT_BASE_URL',
},
},
expression: {
evaluator: {
doc: 'Expression evaluator to use',

View File

@@ -90,6 +90,7 @@ export const LICENSE_FEATURES = {
PROJECT_ROLE_ADMIN: 'feat:projectRole:admin',
PROJECT_ROLE_EDITOR: 'feat:projectRole:editor',
PROJECT_ROLE_VIEWER: 'feat:projectRole:viewer',
AI_ASSISTANT: 'feat:aiAssistant',
COMMUNITY_NODES_CUSTOM_REGISTRY: 'feat:communityNodes:customRegistry',
} as const;

View File

@@ -0,0 +1,44 @@
import { Post, RestController } from '@/decorators';
import { AiAssistantService } from '@/services/aiAsisstant.service';
import { AiAssistantRequest } from '@/requests';
import { Response } from 'express';
import type { AiAssistantSDK } from '@n8n_io/ai-assistant-sdk';
import { Readable, promises } from 'node:stream';
import { InternalServerError } from 'express-openapi-validator/dist/openapi.validator';
import { strict as assert } from 'node:assert';
import { ErrorReporterProxy } from 'n8n-workflow';
@RestController('/ai-assistant')
export class AiAssistantController {
constructor(private readonly aiAssistantService: AiAssistantService) {}
@Post('/chat', { rateLimit: { limit: 100 } })
async chat(req: AiAssistantRequest.Chat, res: Response) {
try {
const stream = await this.aiAssistantService.chat(req.body, req.user);
if (stream.body) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
await promises.pipeline(Readable.fromWeb(stream.body), res);
}
} catch (e) {
// todo add sentry reporting
assert(e instanceof Error);
ErrorReporterProxy.error(e);
throw new InternalServerError({ message: `Something went wrong: ${e.message}` });
}
}
@Post('/chat/apply-suggestion')
async applySuggestion(
req: AiAssistantRequest.ApplySuggestion,
): Promise<AiAssistantSDK.ApplySuggestionResponse> {
try {
return await this.aiAssistantService.applySuggestion(req.body, req.user);
} catch (e) {
assert(e instanceof Error);
ErrorReporterProxy.error(e);
throw new InternalServerError({ message: `Something went wrong: ${e.message}` });
}
}
}

View File

@@ -87,6 +87,7 @@ export class E2EController {
[LICENSE_FEATURES.PROJECT_ROLE_ADMIN]: false,
[LICENSE_FEATURES.PROJECT_ROLE_EDITOR]: false,
[LICENSE_FEATURES.PROJECT_ROLE_VIEWER]: false,
[LICENSE_FEATURES.AI_ASSISTANT]: false,
[LICENSE_FEATURES.COMMUNITY_NODES_CUSTOM_REGISTRY]: false,
};

View File

@@ -25,6 +25,7 @@ import type { Project, ProjectType } from '@db/entities/Project';
import type { ProjectRole } from './databases/entities/ProjectRelation';
import type { Scope } from '@n8n/permissions';
import type { ScopesField } from './services/role.service';
import type { AiAssistantSDK } from '@n8n_io/ai-assistant-sdk';
export class UserUpdatePayload implements Pick<User, 'email' | 'firstName' | 'lastName'> {
@Expose()
@@ -601,3 +602,14 @@ export declare namespace NpsSurveyRequest {
// once some schema validation is added
type NpsSurveyUpdate = AuthenticatedRequest<{}, {}, unknown>;
}
// ----------------------------------
// /ai-assistant
// ----------------------------------
export declare namespace AiAssistantRequest {
type Chat = AuthenticatedRequest<{}, {}, AiAssistantSDK.ChatRequestPayload>;
type SuggestionPayload = { sessionId: string; suggestionId: string };
type ApplySuggestion = AuthenticatedRequest<{}, {}, SuggestionPayload>;
}

View File

@@ -0,0 +1,54 @@
import { Service } from 'typedi';
import type { AiAssistantSDK } from '@n8n_io/ai-assistant-sdk';
import { AiAssistantClient } from '@n8n_io/ai-assistant-sdk';
import { assert, type IUser } from 'n8n-workflow';
import { License } from '../License';
import { N8N_VERSION } from '../constants';
import config from '@/config';
import type { AiAssistantRequest } from '@/requests';
import type { Response } from 'undici';
@Service()
export class AiAssistantService {
private client: AiAssistantClient | undefined;
constructor(private readonly licenseService: License) {}
async init() {
const aiAssistantEnabled = this.licenseService.isAiAssistantEnabled();
if (!aiAssistantEnabled) {
return;
}
const licenseCert = await this.licenseService.loadCertStr();
const consumerId = this.licenseService.getConsumerId();
const baseUrl = config.get('aiAssistant.baseUrl');
const logLevel = config.getEnv('logs.level');
this.client = new AiAssistantClient({
licenseCert,
consumerId,
n8nVersion: N8N_VERSION,
baseUrl,
logLevel,
});
}
async chat(payload: AiAssistantSDK.ChatRequestPayload, user: IUser): Promise<Response> {
if (!this.client) {
await this.init();
}
assert(this.client, 'Assistant client not setup');
return await this.client.chat(payload, { id: user.id });
}
async applySuggestion(payload: AiAssistantRequest.SuggestionPayload, user: IUser) {
if (!this.client) {
await this.init();
}
assert(this.client, 'Assistant client not setup');
return await this.client.applySuggestion(payload, { id: user.id });
}
}

View File

@@ -160,6 +160,9 @@ export class FrontendService {
workflowTagsDisabled: config.getEnv('workflowTagsDisabled'),
logLevel: config.getEnv('logs.level'),
hiringBannerEnabled: config.getEnv('hiringBanner.enabled'),
aiAssistant: {
enabled: false,
},
templates: {
enabled: this.globalConfig.templates.enabled,
host: this.globalConfig.templates.host,
@@ -279,6 +282,7 @@ export class FrontendService {
const isS3Selected = config.getEnv('binaryDataManager.mode') === 's3';
const isS3Available = config.getEnv('binaryDataManager.availableModes').includes('s3');
const isS3Licensed = this.license.isBinaryDataS3Licensed();
const isAiAssistantEnabled = this.license.isAiAssistantEnabled();
this.settings.license.planName = this.license.getPlanName();
this.settings.license.consumerId = this.license.getConsumerId();
@@ -331,6 +335,10 @@ export class FrontendService {
this.settings.missingPackages = this.communityPackagesService.hasMissingPackages;
}
if (isAiAssistantEnabled) {
this.settings.aiAssistant.enabled = isAiAssistantEnabled;
}
this.settings.mfa.enabled = config.get('mfa.enabled');
this.settings.executionMode = config.getEnv('executions.mode');