Skip to content

Commit

Permalink
feat: Add Ask assistant behind feature flag (#9995)
Browse files Browse the repository at this point in the history
Co-authored-by: Ricardo Espinoza <[email protected]>
Co-authored-by: Milorad Filipovic <[email protected]>
  • Loading branch information
3 people authored Aug 14, 2024
1 parent e4c88e7 commit 5ed2a77
Show file tree
Hide file tree
Showing 70 changed files with 3,414 additions and 60 deletions.
1 change: 0 additions & 1 deletion cypress/e2e/19-execution.cy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -275,7 +275,6 @@ describe('Execution', () => {
.within(() => cy.get('.fa-check').should('not.exist'));

successToast().should('be.visible');
clearNotifications();

// Clear execution data
workflowPage.getters.clearExecutionDataButton().should('be.visible');
Expand Down
1 change: 1 addition & 0 deletions packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
4 changes: 4 additions & 0 deletions packages/cli/src/License.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/Server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
9 changes: 9 additions & 0 deletions packages/cli/src/config/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
44 changes: 44 additions & 0 deletions packages/cli/src/controllers/aiAssistant.controller.ts
Original file line number Diff line number Diff line change
@@ -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}` });
}
}
}
1 change: 1 addition & 0 deletions packages/cli/src/controllers/e2e.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};

Expand Down
12 changes: 12 additions & 0 deletions packages/cli/src/requests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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>;
}
54 changes: 54 additions & 0 deletions packages/cli/src/services/aiAsisstant.service.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
}
8 changes: 8 additions & 0 deletions packages/cli/src/services/frontend.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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');
Expand Down
1 change: 1 addition & 0 deletions packages/design-system/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@
"markdown-it-emoji": "^2.0.2",
"markdown-it-link-attributes": "^4.0.1",
"markdown-it-task-lists": "^2.1.1",
"parse-diff": "^0.11.1",
"sanitize-html": "2.12.1",
"vue": "catalog:frontend",
"vue-boring-avatars": "^1.3.0",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import AssistantAvatar from './AssistantAvatar.vue';
import type { StoryFn } from '@storybook/vue3';

export default {
title: 'Assistant/AssistantAvatar',
component: AssistantAvatar,
argTypes: {},
};

const Template: StoryFn = (args, { argTypes }) => ({
setup: () => ({ args }),
props: Object.keys(argTypes),
components: {
AssistantAvatar,
},
template: '<AssistantAvatar v-bind="args" />',
});

export const Default = Template.bind({});
Default.args = {};

export const Mini = Template.bind({});
Mini.args = {
size: 'mini',
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<script setup lang="ts">
import AssistantIcon from '../AskAssistantIcon/AssistantIcon.vue';
withDefaults(defineProps<{ size: 'small' | 'mini' }>(), {
size: 'small',
});
</script>

<template>
<div :class="[$style.container, $style[size]]">
<AssistantIcon :size="size" theme="blank" />
</div>
</template>

<style lang="scss" module>
.container {
background: var(--color-assistant-highlight-gradient);
border-radius: 50%;
display: inline-flex;
align-items: center;
justify-content: center;
}
.small {
height: var(--spacing-m);
width: var(--spacing-m);
}
.mini {
height: var(--spacing-s);
width: var(--spacing-s);
}
</style>
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import AskAssistantButton from './AskAssistantButton.vue';
import { action } from '@storybook/addon-actions';
import type { StoryFn } from '@storybook/vue3';

export default {
title: 'Assistant/AskAssistantButton',
component: AskAssistantButton,
argTypes: {},
};

const methods = {
onClick: action('click'),
};

const Template: StoryFn = (args, { argTypes }) => ({
setup: () => ({ args }),
props: Object.keys(argTypes),
components: {
AskAssistantButton,
},
template:
'<div style="display: flex; height: 50px; width: 300px; align-items: center; justify-content: center"><AskAssistantButton v-bind="args" @click="onClick" /></div>',
methods,
});

export const Button = Template.bind({});

export const Notifications = Template.bind({});
Notifications.args = {
unreadCount: 1,
};
Loading

0 comments on commit 5ed2a77

Please sign in to comment.