diff --git a/cypress/e2e/19-execution.cy.ts b/cypress/e2e/19-execution.cy.ts index 804e81d4e605b..1f7d5c332eb50 100644 --- a/cypress/e2e/19-execution.cy.ts +++ b/cypress/e2e/19-execution.cy.ts @@ -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'); diff --git a/packages/cli/package.json b/packages/cli/package.json index d0f1fc34c3f83..8387f0ba41521 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -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", diff --git a/packages/cli/src/License.ts b/packages/cli/src/License.ts index fc0d2cd45a235..8a5ed18e5808e 100644 --- a/packages/cli/src/License.ts +++ b/packages/cli/src/License.ts @@ -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); } diff --git a/packages/cli/src/Server.ts b/packages/cli/src/Server.ts index 777472b99fa42..49b358f0f254c 100644 --- a/packages/cli/src/Server.ts +++ b/packages/cli/src/Server.ts @@ -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'; diff --git a/packages/cli/src/config/schema.ts b/packages/cli/src/config/schema.ts index a0db9a4c36d16..d3334d4ab1c57 100644 --- a/packages/cli/src/config/schema.ts +++ b/packages/cli/src/config/schema.ts @@ -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', diff --git a/packages/cli/src/constants.ts b/packages/cli/src/constants.ts index dfb072576aa9f..045d5a30e1cba 100644 --- a/packages/cli/src/constants.ts +++ b/packages/cli/src/constants.ts @@ -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; diff --git a/packages/cli/src/controllers/aiAssistant.controller.ts b/packages/cli/src/controllers/aiAssistant.controller.ts new file mode 100644 index 0000000000000..473c20a79dcad --- /dev/null +++ b/packages/cli/src/controllers/aiAssistant.controller.ts @@ -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 { + 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}` }); + } + } +} diff --git a/packages/cli/src/controllers/e2e.controller.ts b/packages/cli/src/controllers/e2e.controller.ts index 20224795052e8..2a93fe8edac16 100644 --- a/packages/cli/src/controllers/e2e.controller.ts +++ b/packages/cli/src/controllers/e2e.controller.ts @@ -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, }; diff --git a/packages/cli/src/requests.ts b/packages/cli/src/requests.ts index 45ee15b76326e..82cf7da30aeb2 100644 --- a/packages/cli/src/requests.ts +++ b/packages/cli/src/requests.ts @@ -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 { @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>; +} diff --git a/packages/cli/src/services/aiAsisstant.service.ts b/packages/cli/src/services/aiAsisstant.service.ts new file mode 100644 index 0000000000000..39a2d950ef1b6 --- /dev/null +++ b/packages/cli/src/services/aiAsisstant.service.ts @@ -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 { + 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 }); + } +} diff --git a/packages/cli/src/services/frontend.service.ts b/packages/cli/src/services/frontend.service.ts index fc38eb992e154..de0abdbfbbe37 100644 --- a/packages/cli/src/services/frontend.service.ts +++ b/packages/cli/src/services/frontend.service.ts @@ -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'); diff --git a/packages/design-system/package.json b/packages/design-system/package.json index 055874a28ac37..acb03f958b602 100644 --- a/packages/design-system/package.json +++ b/packages/design-system/package.json @@ -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", diff --git a/packages/design-system/src/components/AskAssistantAvatar/AssistantAvatar.stories.ts b/packages/design-system/src/components/AskAssistantAvatar/AssistantAvatar.stories.ts new file mode 100644 index 0000000000000..8bf85cca2c50e --- /dev/null +++ b/packages/design-system/src/components/AskAssistantAvatar/AssistantAvatar.stories.ts @@ -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: '', +}); + +export const Default = Template.bind({}); +Default.args = {}; + +export const Mini = Template.bind({}); +Mini.args = { + size: 'mini', +}; diff --git a/packages/design-system/src/components/AskAssistantAvatar/AssistantAvatar.vue b/packages/design-system/src/components/AskAssistantAvatar/AssistantAvatar.vue new file mode 100644 index 0000000000000..9df650ce504f7 --- /dev/null +++ b/packages/design-system/src/components/AskAssistantAvatar/AssistantAvatar.vue @@ -0,0 +1,33 @@ + + + + + diff --git a/packages/design-system/src/components/AskAssistantButton/AskAssistantButton.stories.ts b/packages/design-system/src/components/AskAssistantButton/AskAssistantButton.stories.ts new file mode 100644 index 0000000000000..6d41905aed209 --- /dev/null +++ b/packages/design-system/src/components/AskAssistantButton/AskAssistantButton.stories.ts @@ -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: + '
', + methods, +}); + +export const Button = Template.bind({}); + +export const Notifications = Template.bind({}); +Notifications.args = { + unreadCount: 1, +}; diff --git a/packages/design-system/src/components/AskAssistantButton/AskAssistantButton.vue b/packages/design-system/src/components/AskAssistantButton/AskAssistantButton.vue new file mode 100644 index 0000000000000..5cb1824699db4 --- /dev/null +++ b/packages/design-system/src/components/AskAssistantButton/AskAssistantButton.vue @@ -0,0 +1,95 @@ + + + + + diff --git a/packages/design-system/src/components/AskAssistantChat/AskAssistantChat.stories.ts b/packages/design-system/src/components/AskAssistantChat/AskAssistantChat.stories.ts new file mode 100644 index 0000000000000..ec44622844afb --- /dev/null +++ b/packages/design-system/src/components/AskAssistantChat/AskAssistantChat.stories.ts @@ -0,0 +1,240 @@ +import AskAssistantChat from './AskAssistantChat.vue'; +import type { StoryFn } from '@storybook/vue3'; +import type { ChatUI } from '../../types/assistant'; + +export default { + title: 'Assistant/AskAssistantChat', + component: AskAssistantChat, + argTypes: {}, +}; + +function getMessages(messages: ChatUI.AssistantMessage[]): ChatUI.AssistantMessage[] { + return messages; +} + +const methods = {}; + +const Template: StoryFn = (args, { argTypes }) => ({ + setup: () => ({ args }), + props: Object.keys(argTypes), + components: { + AskAssistantChat, + }, + template: '
', + methods, +}); + +export const DefaultPlaceholderChat = Template.bind({}); +DefaultPlaceholderChat.args = { + user: { + firstName: 'Max', + lastName: 'Test', + }, +}; + +export const Chat = Template.bind({}); +Chat.args = { + user: { + firstName: 'Max', + lastName: 'Test', + }, + messages: getMessages([ + { + id: '1', + type: 'text', + role: 'assistant', + content: 'Hi Max! Here is my top solution to fix the error in your **Transform data** node👇', + read: false, + }, + { + id: '1', + type: 'code-diff', + role: 'assistant', + description: 'Short solution description here that can spill over to two lines', + codeDiff: + '@@ -1,7 +1,6 @@\n-The Way that can be told of is not the eternal Way;\n-The name that can be named is not the eternal name.\nThe Nameless is the origin of Heaven and Earth;\n-The Named is the mother of all things.\n+The named is the mother of all things.\n+\nTherefore let there always be non-being,\nso we may see their subtlety,\nAnd let there always be being,\n@@ -9,3 +8,6 @@\n The two are the same,\n But after they are produced,\n they have different names.\n+They both may be called deep and profound.\n+Deeper and more profound,\n+The door of all subtleties!', + suggestionId: 'test', + quickReplies: [ + { + type: 'new-suggestion', + text: 'Give me another solution', + }, + { + type: 'resolved', + text: 'All good', + }, + ], + read: false, + }, + { + id: '2', + type: 'text', + role: 'user', + content: 'Give it to me **ignore this markdown**', + read: false, + }, + { + id: '2', + type: 'block', + role: 'assistant', + title: 'Credential doesn’t have correct permissions to send a message', + content: + 'Solution steps:\n1. Lorem ipsum dolor sit amet, consectetur **adipiscing** elit. Proin id nulla placerat, tristique ex at, euismod dui.\n2. Copy this into somewhere\n3. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Proin id nulla placerat, tristique ex at, euismod dui.\n4. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Proin id nulla placerat, tristique ex at, euismod dui. \n Testing more code \n - Unordered item 1 \n - Unordered item 2', + read: false, + }, + { + id: '2', + type: 'code-diff', + role: 'assistant', + description: 'Short solution with min height', + codeDiff: + '@@ -1,7 +1,6 @@\n-The Way that can be told of is not the eternal Way;\n-The name that can be named is not the eternal name.\n+The door of all subtleties!', + quickReplies: [ + { + type: 'new-suggestion', + text: 'Give me another solution', + }, + { + type: 'resolved', + text: 'All good', + }, + ], + suggestionId: 'test', + read: false, + }, + ]), +}; + +export const JustSummary = Template.bind({}); +JustSummary.args = { + user: { + firstName: 'Max', + lastName: 'Test', + }, + messages: getMessages([ + { + id: '123', + role: 'assistant', + type: 'block', + title: 'Credential doesn’t have correct permissions to send a message', + content: + 'Solution steps:\n1. Lorem ipsum dolor sit amet, consectetur **adipiscing** elit. Proin id nulla placerat, tristique ex at, euismod dui.\n2. Copy this into somewhere\n3. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Proin id nulla placerat, tristique ex at, euismod dui.\n4. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Proin id nulla placerat, tristique ex at, euismod dui. \n Testing more code \n - Unordered item 1 \n - Unordered item 2', + read: false, + }, + ]), +}; + +export const SummaryTitleStreaming = Template.bind({}); +SummaryTitleStreaming.args = { + user: { + firstName: 'Max', + lastName: 'Test', + }, + messages: getMessages([ + { + id: '123', + role: 'assistant', + type: 'block', + title: 'Credential doesn’t have', + content: '', + read: false, + }, + ]), + streaming: true, +}; + +export const SummaryContentStreaming = Template.bind({}); +SummaryContentStreaming.args = { + user: { + firstName: 'Max', + lastName: 'Test', + }, + messages: getMessages([ + { + id: '123', + role: 'assistant', + type: 'block', + title: 'Credential doesn’t have correct permissions to send a message', + content: 'Solution steps:\n1. Lorem ipsum dolor sit amet, consectetur', + read: false, + }, + ]), + streaming: true, +}; + +export const ErrorChat = Template.bind({}); +ErrorChat.args = { + user: { + firstName: 'Max', + lastName: 'Test', + }, + messages: getMessages([ + { + id: '123', + role: 'assistant', + type: 'error', + content: 'There was an error reaching the service', + read: false, + }, + ]), +}; + +export const EmptyStreamingChat = Template.bind({}); +EmptyStreamingChat.args = { + user: { + firstName: 'Max', + lastName: 'Test', + }, + messages: getMessages([ + { + id: '123', + type: 'text', + role: 'assistant', + content: '', + read: false, + }, + ]), + streaming: true, +}; + +export const StreamingChat = Template.bind({}); +StreamingChat.args = { + user: { + firstName: 'Max', + lastName: 'Test', + }, + messages: getMessages([ + { + id: '123', + type: 'text', + role: 'assistant', + content: 'I am thinking through this problem', + read: false, + }, + ]), + streaming: true, +}; + +export const EndOfSessionChat = Template.bind({}); +EndOfSessionChat.args = { + user: { + firstName: 'Max', + lastName: 'Test', + }, + messages: getMessages([ + { + id: '123', + type: 'text', + role: 'assistant', + content: "Great, glad I could help! I'm here whenever you need more help.", + read: false, + }, + { + id: '123', + role: 'assistant', + type: 'event', + eventName: 'end-session', + read: false, + }, + ]), +}; diff --git a/packages/design-system/src/components/AskAssistantChat/AskAssistantChat.vue b/packages/design-system/src/components/AskAssistantChat/AskAssistantChat.vue new file mode 100644 index 0000000000000..30bdf81351334 --- /dev/null +++ b/packages/design-system/src/components/AskAssistantChat/AskAssistantChat.vue @@ -0,0 +1,471 @@ + + +