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
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
70 changed files with 3414 additions and 60 deletions

View file

@ -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');

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');

View file

@ -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",

View file

@ -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',
};

View file

@ -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>

View file

@ -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,
};

View file

@ -0,0 +1,95 @@
<script setup lang="ts">
import { ref } from 'vue';
import AssistantIcon from '../AskAssistantIcon/AssistantIcon.vue';
import AssistantText from '../AskAssistantText/AssistantText.vue';
import BetaTag from '../BetaTag/BetaTag.vue';
import { useI18n } from '../../composables/useI18n';
const { t } = useI18n();
const hovering = ref(false);
const props = defineProps<{ unreadCount?: number }>();
const emit = defineEmits<{
click: [e: MouseEvent];
}>();
const onClick = (e: MouseEvent) => emit('click', e);
function onMouseEnter() {
hovering.value = true;
}
function onMouseLeave() {
hovering.value = false;
}
</script>
<template>
<button
:class="$style.button"
@mouseenter="onMouseEnter"
@mouseleave="onMouseLeave"
@click="onClick"
>
<div v-if="props.unreadCount" :class="$style.num">
{{ props.unreadCount }}
</div>
<AssistantIcon v-else size="large" :theme="hovering ? 'blank' : 'default'" />
<div v-show="hovering" :class="$style.text">
<div>
<AssistantText :text="t('askAssistantButton.askAssistant')" />
</div>
<div>
<BetaTag />
</div>
</div>
</button>
</template>
<style lang="scss" module>
.button {
border: var(--border-base);
background: var(--color-foreground-xlight);
border-radius: var(--border-radius-base);
display: flex;
align-items: center;
justify-content: center;
height: 42px;
width: 42px;
position: relative;
&:hover {
border: 0;
cursor: pointer;
background: var(--color-assistant-highlight-reverse);
> div {
background: transparent;
}
}
}
.num {
color: var(--prim-color-white);
background: var(--color-assistant-highlight-reverse);
border-radius: 50%;
width: var(--spacing-s);
height: var(--spacing-s);
display: flex;
align-items: center;
justify-content: center;
font-size: var(--font-size-3xs);
}
.text {
position: absolute;
top: -1px;
display: flex;
flex-direction: column;
align-items: end;
width: 100px;
right: 48px;
}
</style>

View file

@ -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: '<div style="width:275px; height:100%"><ask-assistant-chat v-bind="args" /></div>',
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 doesnt 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 doesnt 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 doesnt 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 doesnt 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,
},
]),
};

View file

@ -0,0 +1,471 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import AssistantIcon from '../AskAssistantIcon/AssistantIcon.vue';
import AssistantText from '../AskAssistantText/AssistantText.vue';
import AssistantAvatar from '../AskAssistantAvatar/AssistantAvatar.vue';
import CodeDiff from '../CodeDiff/CodeDiff.vue';
import type { ChatUI } from '../../types/assistant';
import BlinkingCursor from '../BlinkingCursor/BlinkingCursor.vue';
import Markdown from 'markdown-it';
import InlineAskAssistantButton from '../InlineAskAssistantButton/InlineAskAssistantButton.vue';
import BetaTag from '../BetaTag/BetaTag.vue';
import { useI18n } from '../../composables/useI18n';
import markdownLink from 'markdown-it-link-attributes';
const { t } = useI18n();
const md = new Markdown({
breaks: true,
}).use(markdownLink, {
attrs: {
target: '_blank',
rel: 'noopener',
},
});
const MAX_CHAT_INPUT_HEIGHT = 100;
interface Props {
user?: {
firstName: string;
lastName: string;
};
messages?: ChatUI.AssistantMessage[];
streaming?: boolean;
}
const emit = defineEmits<{
close: [];
message: [string, string | undefined];
codeReplace: [number];
codeUndo: [number];
}>();
const onClose = () => emit('close');
const props = defineProps<Props>();
const textInputValue = ref<string>('');
const chatInput = ref<HTMLTextAreaElement | null>(null);
const sessionEnded = computed(() => {
return isEndOfSessionEvent(props.messages?.[props.messages.length - 1]);
});
const sendDisabled = computed(() => {
return !textInputValue.value || props.streaming || sessionEnded.value;
});
function isEndOfSessionEvent(event?: ChatUI.AssistantMessage) {
return event?.type === 'event' && event?.eventName === 'end-session';
}
function onQuickReply(opt: ChatUI.QuickReply) {
emit('message', opt.text, opt.type);
}
function onSendMessage() {
if (sendDisabled.value) return;
emit('message', textInputValue.value, undefined);
textInputValue.value = '';
if (chatInput.value) {
chatInput.value.style.height = 'auto';
}
}
function renderMarkdown(content: string) {
try {
return md.render(content);
} catch (e) {
console.error(`Error parsing markdown content ${content}`);
return `<p>${t('assistantChat.errorParsingMarkdown')}</p>`;
}
}
function growInput() {
if (!chatInput.value) return;
chatInput.value.style.height = 'auto';
const scrollHeight = chatInput.value.scrollHeight;
chatInput.value.style.height = `${Math.min(scrollHeight, MAX_CHAT_INPUT_HEIGHT)}px`;
}
</script>
<template>
<div :class="$style.container">
<div :class="$style.header">
<div :class="$style.chatTitle">
<div :class="$style.headerText">
<AssistantIcon size="large" />
<AssistantText size="large" :text="t('assistantChat.aiAssistantLabel')" />
</div>
<BetaTag />
</div>
<div :class="$style.back" @click="onClose">
<n8n-icon icon="arrow-right" color="text-base" />
</div>
</div>
<div :class="$style.body">
<div v-if="messages?.length" :class="$style.messages">
<div v-for="(message, i) in messages" :key="i" :class="$style.message">
<div
v-if="
!isEndOfSessionEvent(message) && (i === 0 || message.role !== messages[i - 1].role)
"
:class="{ [$style.roleName]: true, [$style.userSection]: i > 0 }"
>
<AssistantAvatar v-if="message.role === 'assistant'" />
<n8n-avatar
v-else
:first-name="user?.firstName"
:last-name="user?.lastName"
size="xsmall"
/>
<span v-if="message.role === 'assistant'">{{
t('assistantChat.aiAssistantName')
}}</span>
<span v-else>{{ t('assistantChat.you') }}</span>
</div>
<div v-if="message.type === 'block'">
<div :class="$style.block">
<div :class="$style.blockTitle">
{{ message.title }}
<BlinkingCursor
v-if="streaming && i === messages?.length - 1 && !message.content"
/>
</div>
<div :class="$style.blockBody">
<!-- eslint-disable-next-line vue/no-v-html -->
<span v-html="renderMarkdown(message.content)"></span>
<BlinkingCursor
v-if="streaming && i === messages?.length - 1 && message.title && message.content"
/>
</div>
</div>
</div>
<div v-else-if="message.type === 'text'" :class="$style.textMessage">
<!-- eslint-disable-next-line vue/no-v-html -->
<span v-if="message.role === 'user'" v-html="renderMarkdown(message.content)"></span>
<!-- eslint-disable-next-line vue/no-v-html -->
<span
v-else
:class="$style.assistantText"
v-html="renderMarkdown(message.content)"
></span>
<BlinkingCursor
v-if="streaming && i === messages?.length - 1 && message.role === 'assistant'"
/>
</div>
<div v-else-if="message.type === 'error'" :class="$style.error">
<span> {{ message.content }}</span>
</div>
<div v-else-if="message.type === 'code-diff'">
<CodeDiff
:title="message.description"
:content="message.codeDiff"
:replacing="message.replacing"
:replaced="message.replaced"
:error="message.error"
:streaming="streaming && i === messages?.length - 1"
@replace="() => emit('codeReplace', i)"
@undo="() => emit('codeUndo', i)"
/>
</div>
<div v-else-if="isEndOfSessionEvent(message)" :class="$style.endOfSessionText">
<span>
{{ t('assistantChat.sessionEndMessage.1') }}
</span>
<InlineAskAssistantButton size="small" :static="true" />
<span>
{{ t('assistantChat.sessionEndMessage.2') }}
</span>
</div>
<div
v-if="
!streaming &&
'quickReplies' in message &&
message.quickReplies?.length &&
i === messages?.length - 1
"
:class="$style.quickReplies"
>
<div :class="$style.quickRepliesTitle">{{ t('assistantChat.quickRepliesTitle') }}</div>
<div v-for="opt in message.quickReplies" :key="opt.type">
<n8n-button
v-if="opt.text"
type="secondary"
size="mini"
@click="() => onQuickReply(opt)"
>
{{ opt.text }}
</n8n-button>
</div>
</div>
</div>
</div>
<div v-else :class="$style.placeholder">
<div :class="$style.greeting">Hi {{ user?.firstName }} 👋</div>
<div :class="$style.info">
<p>
{{
t('assistantChat.placeholder.1', [
`${user?.firstName}`,
t('assistantChat.aiAssistantName'),
])
}}
</p>
<p>
{{ t('assistantChat.placeholder.2') }}
<InlineAskAssistantButton size="small" :static="true" />
{{ t('assistantChat.placeholder.3') }}
</p>
<p>
{{ t('assistantChat.placeholder.4') }}
</p>
</div>
</div>
</div>
<div
v-if="messages?.length"
:class="{ [$style.inputWrapper]: true, [$style.disabledInput]: sessionEnded }"
>
<textarea
ref="chatInput"
v-model="textInputValue"
:disabled="sessionEnded"
:placeholder="t('assistantChat.inputPlaceholder')"
rows="1"
wrap="hard"
@keydown.enter.exact.prevent="onSendMessage"
@input.prevent="growInput"
@keydown.stop
/>
<n8n-icon-button
:class="{ [$style.sendButton]: true }"
icon="paper-plane"
type="text"
size="large"
:disabled="sendDisabled"
@click="onSendMessage"
/>
</div>
</div>
</template>
<style lang="scss" module>
.container {
height: 100%;
position: relative;
}
p {
line-height: var(--font-line-height-xloose);
margin: var(--spacing-2xs) 0;
}
.header {
height: 65px; // same as header height in editor
padding: 0 var(--spacing-l);
background-color: var(--color-background-xlight);
border: var(--border-base);
border-top: 0;
display: flex;
div {
display: flex;
align-items: center;
}
> div:first-of-type {
width: 100%;
}
}
.body {
background-color: var(--color-background-light);
border: var(--border-base);
border-top: 0;
height: 100%;
overflow: scroll;
padding-bottom: 250px; // make scrollable at the end
position: relative;
pre {
text-wrap: stable;
}
}
.placeholder {
padding: var(--spacing-s);
}
.messages {
padding: var(--spacing-xs);
}
.message {
margin-bottom: var(--spacing-xs);
font-size: var(--font-size-2xs);
line-height: var(--font-line-height-xloose);
}
.roleName {
display: flex;
align-items: center;
margin-bottom: var(--spacing-3xs);
font-weight: var(--font-weight-bold);
font-size: var(--font-size-2xs);
> * {
margin-right: var(--spacing-3xs);
}
}
.userSection {
margin-top: var(--spacing-l);
}
.chatTitle {
display: flex;
gap: var(--spacing-3xs);
}
.headerText {
gap: var(--spacing-xs);
}
.greeting {
color: var(--color-text-dark);
font-size: var(--font-size-m);
margin-bottom: var(--spacing-s);
}
.info {
font-size: var(--font-size-s);
color: var(--color-text-base);
button {
display: inline-flex;
}
}
.back:hover {
cursor: pointer;
}
.quickReplies {
margin-top: var(--spacing-s);
> * {
margin-bottom: var(--spacing-3xs);
}
}
.quickRepliesTitle {
font-size: var(--font-size-3xs);
color: var(--color-text-base);
}
.textMessage {
font-size: var(--font-size-2xs);
}
.block {
font-size: var(--font-size-2xs);
background-color: var(--color-foreground-xlight);
border: var(--border-base);
border-radius: var(--border-radius-base);
word-break: break-word;
li {
margin-left: var(--spacing-xs);
}
}
.blockTitle {
border-bottom: var(--border-base);
padding: var(--spacing-2xs);
font-weight: var(--font-weight-bold);
}
.blockBody {
padding: var(--spacing-xs);
}
.inputWrapper {
position: absolute;
display: flex;
bottom: 0;
background-color: var(--color-foreground-xlight);
border: var(--border-base);
width: 100%;
padding-top: 1px;
textarea {
border: none;
background-color: transparent;
width: 100%;
font-size: var(--spacing-xs);
padding: var(--spacing-xs);
outline: none;
color: var(--color-text-dark);
resize: none;
font-family: inherit;
}
}
.sendButton {
color: var(--color-text-base) !important;
&[disabled] {
color: var(--color-text-light) !important;
}
}
.error {
color: var(--color-danger);
}
.assistantText {
display: inline;
p {
display: inline;
line-height: 1.7;
}
ul,
ol {
list-style-position: inside;
margin: var(--spacing-xs) 0 var(--spacing-xs) var(--spacing-xs);
}
}
.endOfSessionText {
margin-top: var(--spacing-l);
padding-top: var(--spacing-3xs);
border-top: var(--border-base);
color: var(--color-text-base);
> button,
> span {
margin-right: var(--spacing-3xs);
}
button {
display: inline-flex;
}
}
.disabledInput {
cursor: not-allowed;
* {
cursor: not-allowed;
}
}
</style>

View file

@ -0,0 +1,49 @@
import AssistantIcon from './AssistantIcon.vue';
import type { StoryFn } from '@storybook/vue3';
export default {
title: 'Assistant/AssistantIcon',
component: AssistantIcon,
argTypes: {},
};
const Template: StoryFn = (args, { argTypes }) => ({
setup: () => ({ args }),
props: Object.keys(argTypes),
components: {
AssistantIcon,
},
template: '<div style="background: lightgray;"><AssistantIcon v-bind="args" /></div>',
});
export const Default = Template.bind({});
Default.args = {
theme: 'default',
};
export const Blank = Template.bind({
template: '<div style="background=black;"><AssistantIcon v-bind="args" /></div>',
});
Blank.args = {
theme: 'blank',
};
export const Mini = Template.bind({});
Mini.args = {
size: 'mini',
};
export const Small = Template.bind({});
Small.args = {
size: 'small',
};
export const Medium = Template.bind({});
Medium.args = {
size: 'medium',
};
export const Large = Template.bind({});
Large.args = {
size: 'large',
};

View file

@ -0,0 +1,59 @@
<script setup lang="ts">
import { computed } from 'vue';
const props = withDefaults(
defineProps<{
size: 'mini' | 'small' | 'medium' | 'large';
theme: 'default' | 'blank' | 'disabled';
}>(),
{
size: 'medium',
theme: 'default',
},
);
const sizes = {
mini: 8,
small: 10,
medium: 12,
large: 18,
};
const svgFill = computed(() => {
if (props.theme === 'blank') {
return 'white';
} else if (props.theme === 'disabled') {
return 'var(--color-text-light)';
}
return 'url(#paint0_linear_173_12825)';
});
</script>
<template>
<svg
:width="sizes[size]"
:height="sizes[size]"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M19.9658 14.0171C19.9679 14.3549 19.8654 14.6851 19.6722 14.9622C19.479 15.2393 19.2046 15.4497 18.8869 15.5645L13.5109 17.5451L11.5303 22.9211C11.4137 23.2376 11.2028 23.5107 10.9261 23.7037C10.6494 23.8966 10.3202 24 9.9829 24C9.64559 24 9.3164 23.8966 9.0397 23.7037C8.76301 23.5107 8.55212 23.2376 8.43549 22.9211L6.45487 17.5451L1.07888 15.5645C0.762384 15.4479 0.489262 15.237 0.296347 14.9603C0.103431 14.6836 0 14.3544 0 14.0171C0 13.6798 0.103431 13.3506 0.296347 13.0739C0.489262 12.7972 0.762384 12.5863 1.07888 12.4697L6.45487 10.4891L8.43549 5.11309C8.55212 4.79659 8.76301 4.52347 9.0397 4.33055C9.3164 4.13764 9.64559 4.0342 9.9829 4.0342C10.3202 4.0342 10.6494 4.13764 10.9261 4.33055C11.2028 4.52347 11.4137 4.79659 11.5303 5.11309L13.5109 10.4891L18.8869 12.4697C19.2046 12.5845 19.479 12.7949 19.6722 13.072C19.8654 13.3491 19.9679 13.6793 19.9658 14.0171ZM14.1056 4.12268H15.7546V5.77175C15.7546 5.99043 15.8415 6.20015 15.9961 6.35478C16.1508 6.50941 16.3605 6.59628 16.5792 6.59628C16.7979 6.59628 17.0076 6.50941 17.1622 6.35478C17.3168 6.20015 17.4037 5.99043 17.4037 5.77175V4.12268H19.0528C19.2715 4.12268 19.4812 4.03581 19.6358 3.88118C19.7905 3.72655 19.8773 3.51682 19.8773 3.29814C19.8773 3.07946 19.7905 2.86974 19.6358 2.71511C19.4812 2.56048 19.2715 2.47361 19.0528 2.47361H17.4037V0.824535C17.4037 0.605855 17.3168 0.396131 17.1622 0.241501C17.0076 0.0868704 16.7979 0 16.5792 0C16.3605 0 16.1508 0.0868704 15.9961 0.241501C15.8415 0.396131 15.7546 0.605855 15.7546 0.824535V2.47361H14.1056C13.8869 2.47361 13.6772 2.56048 13.5225 2.71511C13.3679 2.86974 13.281 3.07946 13.281 3.29814C13.281 3.51682 13.3679 3.72655 13.5225 3.88118C13.6772 4.03581 13.8869 4.12268 14.1056 4.12268ZM23.1755 7.42082H22.3509V6.59628C22.3509 6.3776 22.2641 6.16788 22.1094 6.01325C21.9548 5.85862 21.7451 5.77175 21.5264 5.77175C21.3077 5.77175 21.098 5.85862 20.9434 6.01325C20.7887 6.16788 20.7019 6.3776 20.7019 6.59628V7.42082H19.8773C19.6586 7.42082 19.4489 7.50769 19.2943 7.66232C19.1397 7.81695 19.0528 8.02667 19.0528 8.24535C19.0528 8.46404 19.1397 8.67376 19.2943 8.82839C19.4489 8.98302 19.6586 9.06989 19.8773 9.06989H20.7019V9.89443C20.7019 10.1131 20.7887 10.3228 20.9434 10.4775C21.098 10.6321 21.3077 10.719 21.5264 10.719C21.7451 10.719 21.9548 10.6321 22.1094 10.4775C22.2641 10.3228 22.3509 10.1131 22.3509 9.89443V9.06989H23.1755C23.3941 9.06989 23.6039 8.98302 23.7585 8.82839C23.9131 8.67376 24 8.46404 24 8.24535C24 8.02667 23.9131 7.81695 23.7585 7.66232C23.6039 7.50769 23.3941 7.42082 23.1755 7.42082Z"
:fill="svgFill"
/>
<defs>
<linearGradient
id="paint0_linear_173_12825"
x1="-3.67094e-07"
y1="-0.000120994"
x2="28.8315"
y2="9.82667"
gradientUnits="userSpaceOnUse"
>
<stop stop-color="var(--color-assistant-highlight-1)" />
<stop offset="0.495" stop-color="var(--color-assistant-highlight-2)" />
<stop offset="1" stop-color="var(--color-assistant-highlight-3)" />
</linearGradient>
</defs>
</svg>
</template>

View file

@ -0,0 +1,40 @@
import AssistantText from './AssistantText.vue';
import type { StoryFn } from '@storybook/vue3';
export default {
title: 'Assistant/AssistantText',
component: AssistantText,
argTypes: {},
};
const Template: StoryFn = (args, { argTypes }) => ({
setup: () => ({ args }),
props: Object.keys(argTypes),
components: {
AssistantText,
},
template: '<AssistantText v-bind="args" />',
});
export const Default = Template.bind({});
Default.args = {
text: 'Ask me something!!!',
};
export const Small = Template.bind({});
Small.args = {
text: 'Ask me something!!!',
size: 'small',
};
export const Medium = Template.bind({});
Medium.args = {
text: 'Ask me something!!!',
size: 'medium',
};
export const Large = Template.bind({});
Large.args = {
text: 'Ask me something!!!',
size: 'large',
};

View file

@ -0,0 +1,40 @@
<script setup lang="ts">
withDefaults(defineProps<{ text: string; size: 'small' | 'medium' | 'large' | 'xlarge' }>(), {
text: '',
size: 'medium',
});
</script>
<template>
<span :class="[$style.text, $style[size]]">{{ text }}</span>
</template>
<style lang="scss" module>
.text {
background: var(--color-assistant-highlight-gradient);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
font-weight: var(--font-weight-bold);
}
.small {
font-size: 9px;
line-height: var(--spacing-xs);
}
.medium {
font-size: var(--spacing-xs);
line-height: var(--spacing-s);
}
.large {
font-size: 14px;
line-height: 18px;
}
.xlarge {
font-size: var(--spacing-s);
line-height: var(--spacing-s);
}
</style>

View file

@ -0,0 +1,19 @@
import BetaTag from './BetaTag.vue';
import type { StoryFn } from '@storybook/vue3';
export default {
title: 'Assistant/BetaTag',
component: BetaTag,
argTypes: {},
};
const Template: StoryFn = (args, { argTypes }) => ({
setup: () => ({ args }),
props: Object.keys(argTypes),
components: {
BetaTag,
},
template: '<BetaTag v-bind="args" />',
});
export const Beta = Template.bind({});

View file

@ -0,0 +1,22 @@
<script lang="ts" setup>
import { useI18n } from '../../composables/useI18n';
const { t } = useI18n();
</script>
<template>
<div :class="$style.beta">{{ t('betaTag.beta') }}</div>
</template>
<style lang="scss" module>
.beta {
display: inline-block;
color: var(--color-secondary);
font-size: var(--font-size-3xs);
font-weight: var(--font-weight-bold);
background-color: var(--color-secondary-tint-3);
padding: var(--spacing-5xs) var(--spacing-4xs);
border-radius: 16px;
}
</style>

View file

@ -0,0 +1,19 @@
import BlinkingCursor from './BlinkingCursor.vue';
import type { StoryFn } from '@storybook/vue3';
export default {
title: 'Assistant/BlinkingCursor',
component: BlinkingCursor,
argTypes: {},
};
const Template: StoryFn = (args, { argTypes }) => ({
setup: () => ({ args }),
props: Object.keys(argTypes),
components: {
BlinkingCursor,
},
template: '<blinking-cursor v-bind="args" />',
});
export const Cursor = Template.bind({});

View file

@ -0,0 +1,25 @@
<template>
<span class="blinking-cursor"></span>
</template>
<style lang="scss">
.blinking-cursor {
display: inline-block;
height: var(--font-size-m);
width: var(--spacing-3xs);
border-radius: var(--border-radius-small);
margin-left: var(--spacing-4xs);
animation: 1s blink step-end infinite;
}
@keyframes blink {
from,
to {
background-color: transparent;
}
50% {
background-color: var(--color-foreground-xdark);
}
}
</style>

View file

@ -0,0 +1,96 @@
import CodeDiff from './CodeDiff.vue';
import type { StoryFn } from '@storybook/vue3';
export default {
title: 'Assistant/CodeDiff',
component: CodeDiff,
argTypes: {},
};
const methods = {};
const Template: StoryFn = (args, { argTypes }) => ({
setup: () => ({ args }),
props: Object.keys(argTypes),
components: {
CodeDiff,
},
template: '<div style="width:300px; height:100%"><code-diff v-bind="args" /></div>',
methods,
});
export const Example = Template.bind({});
Example.args = {
title: 'Lao Tzu example unified diff',
content:
'@@ -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!',
};
export const Empty = Template.bind({});
Empty.args = {};
export const Code = Template.bind({});
Code.args = {
title: "Fix reference to the node and remove unsupported 'require' statement.",
content:
"--- original.js\n+++ modified.js\n@@ -1,2 +1,2 @@\n-const SIGNING_SECRET = $input.first().json.slack_secret_signature;\n-const item = $('Webhook to call for Slack command').first();\n+const SIGNING_SECRET = items[0].json.slack_secret_signature;\n+const item = items[0];\n@@ -7,8 +7,6 @@\n}\n\n-const crypto = require('crypto');\n-\n const { binary: { data } } = item;\n\n if (\n@@ -22,7 +20,7 @@\n const rawBody = Buffer.from(data.data, 'base64').toString()\n \n // compute the ",
streaming: true,
};
export const StreamingTitleEmpty = Template.bind({});
StreamingTitleEmpty.args = {
streaming: true,
};
export const StreamingTitle = Template.bind({});
StreamingTitle.args = {
streaming: true,
title: 'Hello world',
};
export const StreamingContentWithOneLine = Template.bind({});
StreamingContentWithOneLine.args = {
streaming: true,
title: 'Hello world',
content: '@@ -1,7 +1,6 @@\n-The Way that can be told of is not th',
};
export const StreamingContentWithMultipleLines = Template.bind({});
StreamingContentWithMultipleLines.args = {
streaming: true,
title: 'Hello world',
content:
'@@ -1,7 +1,6 @@\n-The Way that can be told of is not the eternal Way;\n-The name that can b',
};
export const StreamingWithManyManyLines = Template.bind({});
StreamingWithManyManyLines.args = {
title: 'Lao Tzu example unified diff',
content:
'@@ -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!',
streaming: true,
};
export const Replaced = Template.bind({});
Replaced.args = {
title: 'Lao Tzu example unified diff',
content:
'@@ -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!',
replaced: true,
};
export const Replacing = Template.bind({});
Replacing.args = {
title: 'Lao Tzu example unified diff',
content:
'@@ -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!',
replacing: true,
};
export const Error = Template.bind({});
Error.args = {
title: 'Lao Tzu example unified diff',
content:
'@@ -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!',
error: true,
};

View file

@ -0,0 +1,207 @@
<script setup lang="ts">
import parseDiff from 'parse-diff';
import { computed } from 'vue';
import { useI18n } from 'n8n-design-system/composables/useI18n';
const MIN_LINES = 4;
interface Props {
title: string;
content: string;
replacing: boolean;
replaced: boolean;
error: boolean;
streaming: boolean;
}
type Line =
| parseDiff.Change
| {
type: 'filler' | 'seperator';
content: string;
};
const props = withDefaults(defineProps<Props>(), {
title: '',
content: '',
replacing: false,
replaced: false,
error: false,
streaming: false,
});
const emit = defineEmits<{
replace: [];
undo: [];
}>();
const { t } = useI18n();
const diffs = computed(() => {
const parsed = parseDiff(props.content);
const file = parsed[0] ?? { chunks: [] };
const lines: Line[] = file.chunks.reduce((accu: Line[], chunk, i) => {
const changes: Line[] = chunk.changes.map((change) => {
let content = change.content;
if (change.type === 'add' && content.startsWith('+')) {
content = content.replace('+', '');
} else if (change.type === 'del' && content.startsWith('-')) {
content = content.replace('-', '');
}
return {
...change,
content,
};
});
if (i !== file.chunks.length - 1) {
changes.push({
type: 'seperator',
content: '...',
});
}
return [...accu, ...changes];
}, []);
const len = lines.length;
// why programmatic and not min height? to ensure numbers border goes all the way down.
if (len <= MIN_LINES) {
for (let i = 0; i < MIN_LINES - len; i++) {
lines.push({
type: 'filler',
content: '',
});
}
}
return lines;
});
</script>
<template>
<div :class="$style.container">
<div :class="$style.title">
{{ title }}
</div>
<div :class="$style.diffSection">
<div v-for="(diff, i) in diffs" :key="i" :class="$style.diff">
<div :class="$style.lineNumber">
<!-- ln1 is line number in original text -->
<!-- ln2 is line number in updated text -->
{{ diff.type === 'normal' ? diff.ln2 : diff.type === 'add' ? diff.ln : '' }}
</div>
<div :class="[$style[diff.type], $style.diffContent]">
<span v-if="diff.type === 'add'">&nbsp;+&nbsp;</span>
<span v-else-if="diff.type === 'del'">&nbsp;-&nbsp;</span>
<span v-else>&nbsp;&nbsp;&nbsp;</span>
<span>
{{ diff.content }}
</span>
</div>
</div>
</div>
<div :class="$style.actions">
<div v-if="error">
<n8n-icon icon="exclamation-triangle" color="danger" class="mr-5xs" />
<span :class="$style.infoText">{{ t('codeDiff.couldNotReplace') }}</span>
</div>
<div v-else-if="replaced">
<n8n-button type="secondary" size="mini" icon="undo" @click="() => emit('undo')">{{
t('codeDiff.undo')
}}</n8n-button>
<n8n-icon icon="check" color="success" class="ml-xs" />
<span :class="$style.infoText">{{ t('codeDiff.codeReplaced') }}</span>
</div>
<n8n-button
v-else
:type="replacing ? 'secondary' : 'primary'"
size="mini"
icon="refresh"
:disabled="!content || streaming"
:loading="replacing"
@click="() => emit('replace')"
>{{ replacing ? t('codeDiff.replacing') : t('codeDiff.replaceMyCode') }}</n8n-button
>
</div>
</div>
</template>
<style lang="scss" module>
.container {
border: var(--border-base);
background-color: var(--color-foreground-xlight);
border-radius: var(--border-radius-base);
}
.title {
padding: var(--spacing-2xs);
font-weight: var(--font-weight-bold);
font-size: var(--font-size-2xs);
// ensure consistent spacing even if title is empty
min-height: 32.5px;
line-height: normal;
display: flex;
}
.lineNumber {
font-size: var(--font-size-3xs);
min-width: 18px;
max-width: 18px;
text-align: center;
border-right: var(--border-base);
}
.diffSection {
overflow: scroll;
border-top: var(--border-base);
border-bottom: var(--border-base);
max-height: 218px; // 12 lines
background-color: var(--color-background-base);
font-family: var(--font-family-monospace);
}
.diff {
display: flex;
font-size: var(--font-size-3xs);
line-height: 18px; /* 100% */
height: 18px;
max-height: 18px;
}
.diffContent {
width: auto;
text-wrap: nowrap;
display: flex;
> span {
display: flex;
}
}
.add {
color: var(--color-success);
background-color: var(--color-success-tint-2);
}
.del {
color: var(--color-danger);
background-color: var(--color-danger-tint-2);
}
.normal {
background-color: var(--color-foreground-xlight);
}
.actions {
padding: var(--spacing-2xs);
}
.infoText {
color: var(--color-text-light);
font-size: var(--font-size-xs);
margin-left: var(--spacing-4xs);
}
</style>

View file

@ -0,0 +1,36 @@
import InlineAskAssistantButton from './InlineAskAssistantButton.vue';
import { action } from '@storybook/addon-actions';
import type { StoryFn } from '@storybook/vue3';
export default {
title: 'Assistant/InlineAskAssistantButton',
component: InlineAskAssistantButton,
argTypes: {},
};
const methods = {
onClick: action('click'),
};
const Template: StoryFn = (args, { argTypes }) => ({
setup: () => ({ args }),
props: Object.keys(argTypes),
components: {
InlineAskAssistantButton,
},
template: '<InlineAskAssistantButton v-bind="args" @click="onClick" />',
methods,
});
export const Default = Template.bind({});
export const AskedButton = Template.bind({});
AskedButton.args = {
asked: true,
};
export const Small = Template.bind({});
Small.args = { size: 'small' };
export const Static = Template.bind({});
Static.args = { static: true };

View file

@ -0,0 +1,125 @@
<script setup lang="ts">
import { computed } from 'vue';
import AssistantIcon from '../AskAssistantIcon/AssistantIcon.vue';
import AssistantText from '../AskAssistantText/AssistantText.vue';
import { useI18n } from '../../composables/useI18n';
const { t } = useI18n();
interface Props {
size: 'small' | 'medium';
static: boolean;
asked: boolean;
}
const props = withDefaults(defineProps<Props>(), {
size: 'medium',
static: false,
asked: false,
});
const emit = defineEmits<{
click: [];
}>();
const sizes = {
medium: {
padding: '0px 12px',
height: '28px',
},
small: {
padding: '0px 6px',
height: '18px',
},
};
const hoverable = computed(() => !props.static && !props.asked);
const onClick = () => {
if (hoverable.value) {
emit('click');
}
};
// todo hoverable class not clean below
</script>
<template>
<button
:class="{ [$style.button]: true, [$style.hoverable]: hoverable, [$style.asked]: asked }"
:style="{ height: sizes[size].height }"
:disabled="asked"
:tabindex="static ? '-1' : ''"
@click="onClick"
>
<div>
<div :style="{ padding: sizes[size].padding }">
<AssistantIcon :size="size" :class="$style.icon" :theme="asked ? 'disabled' : 'default'" />
<span v-if="asked">{{ t('inlineAskAssistantButton.asked') }}</span>
<AssistantText v-else :size="size" :text="t('askAssistantButton.askAssistant')" />
</div>
</div>
</button>
</template>
<style lang="scss" module>
.button {
border-radius: var(--border-radius-base);
position: relative;
border: 0;
padding: 1px;
background: var(--color-assistant-highlight-gradient);
> div {
background-color: var(--color-askAssistant-button-background);
border-radius: inherit;
height: 100%;
overflow: hidden;
> div {
height: 100%;
display: flex;
align-items: center;
justify-content: center;
line-height: unset;
}
}
}
.hoverable {
&:hover {
cursor: pointer;
background: var(--color-assistant-highlight-reverse);
> div {
background: var(--color-askAssistant-button-background-hover);
}
> div > div {
background: var(--color-assistant-inner-highlight-hover);
}
}
&:active {
background: var(--color-assistant-highlight-gradient);
> div {
background: var(--color-askAssistant-button-background-active);
}
> div > div {
background: var(--color-assistant-inner-highlight-active);
}
}
}
.asked {
cursor: not-allowed;
background: var(--color-foreground-base);
color: var(--color-text-light);
}
.icon {
margin-right: var(--spacing-3xs);
}
</style>

View file

@ -7,8 +7,10 @@
variant="marble"
:colors="getColors(colors)"
/>
<span v-else :class="[$style.empty, $style[size]]" />
<span v-if="name" :class="$style.initials">{{ initials }}</span>
<div v-else :class="[$style.empty, $style[size]]"></div>
<span v-if="firstName || lastName" :class="[$style.initials, $style[`text-${size}`]]">
{{ initials }}
</span>
</span>
</template>
@ -20,7 +22,7 @@ import { getInitials } from '../../utils/labelUtil';
interface AvatarProps {
firstName: string;
lastName: string;
size?: string;
size?: 'xsmall' | 'small' | 'medium' | 'large';
colors?: string[];
}
@ -47,6 +49,7 @@ const getColors = (colors: string[]): string[] => {
};
const sizes: { [size: string]: number } = {
xsmall: 20,
small: 28,
large: 48,
medium: 40,
@ -78,6 +81,15 @@ const getSize = (size: string): number => sizes[size];
text-transform: uppercase;
}
.text-xsmall {
font-size: 6px;
}
.xsmall {
height: var(--spacing-m);
width: var(--spacing-m);
}
.small {
height: 28px;
width: 28px;

View file

@ -13,8 +13,8 @@
}"
>
<span v-if="loading || icon" :class="$style.icon">
<N8nSpinner v-if="loading" :size="size" />
<N8nIcon v-else-if="icon" :icon="icon" :size="size" />
<N8nSpinner v-if="loading" :size="iconSize" />
<N8nIcon v-else-if="icon" :icon="icon" :size="iconSize" />
</span>
<span v-if="label || $slots.default">
<slot>{{ label }}</slot>
@ -56,6 +56,8 @@ const ariaBusy = computed(() => (props.loading ? 'true' : undefined));
const ariaDisabled = computed(() => (props.disabled ? 'true' : undefined));
const isDisabled = computed(() => props.disabled || props.loading);
const iconSize = computed(() => (props.size === 'mini' ? 'xsmall' : props.size));
const classes = computed(() => {
return (
`button ${$style.button} ${$style[props.type]}` +

View file

@ -17,13 +17,14 @@ import N8nIcon from '../N8nIcon';
const TYPE = ['dots', 'ring'] as const;
interface SpinnerProps {
size?: Exclude<TextSize, 'xsmall' | 'mini' | 'xlarge'>;
size?: Exclude<TextSize, 'mini' | 'xlarge'>;
type?: (typeof TYPE)[number];
}
defineOptions({ name: 'N8nSpinner' });
withDefaults(defineProps<SpinnerProps>(), {
type: 'dots',
size: 'medium',
});
</script>

View file

@ -465,6 +465,8 @@
var(--prim-color-alt-k-s),
var(--prim-color-alt-k-l)
);
--prim-color-white: hsl(0, 0%, 100%);
}
:root {

View file

@ -33,6 +33,29 @@
// Secondary tokens
// AI Assistant
--color-assistant-highlight-1: #8c90f2;
--color-assistant-highlight-2: #a977f0;
--color-assistant-highlight-3: #f0778b;
--color-askAssistant-button-background: #2E2E2E;
--color-askAssistant-button-background-hover: #383839;
--color-askAssistant-button-background-active: #414244;
--color-assistant-inner-highlight-hover: var(--color-askAssistant-button-background-hover);
--color-assistant-inner-highlight-active: var(--color-askAssistant-button-background-active);
--color-assistant-highlight-gradient: linear-gradient(
105deg,
var(--color-assistant-highlight-1) 0%,
var(--color-assistant-highlight-2) 50%,
var(--color-assistant-highlight-3) 100%
);
--color-assistant-highlight-reverse: linear-gradient(
105deg,
var(--color-assistant-highlight-3) 0%,
var(--color-assistant-highlight-2) 50%,
var(--color-assistant-highlight-1) 100%
);
// LangChain
--color-lm-chat-messages-background: var(--prim-gray-820);
--color-lm-chat-bot-background: var(--prim-gray-740);
@ -196,7 +219,7 @@
--color-notice-font: var(--prim-gray-0);
// Callout
--color-callout-info-border: var(--prim-gray-420);
--color-callout-info-border: var(--prim-gray-670);
--color-callout-info-background: var(--prim-gray-740);
--color-callout-info-font: var(--prim-gray-0);
--color-callout-success-border: var(--color-success);

View file

@ -110,6 +110,39 @@
--color-sticky-background-7: var(--prim-gray-10);
--color-sticky-border-7: var(--prim-gray-120);
// AI Assistant
--color-askAssistant-button-background: var(--color-background-xlight);
--color-askAssistant-button-background-hover: var(--color-background-xlight);
--color-askAssistant-button-background-active: var(--color-background-xlight);
--color-assistant-highlight-1: #5b60e8;
--color-assistant-highlight-2: #aa7bec;
--color-assistant-highlight-3: #ec7b8e;
--color-assistant-highlight-gradient: linear-gradient(
105deg,
var(--color-assistant-highlight-1) 0%,
var(--color-assistant-highlight-2) 50%,
var(--color-assistant-highlight-3) 100%
);
--color-assistant-highlight-reverse: linear-gradient(
105deg,
var(--color-assistant-highlight-3) 0%,
var(--color-assistant-highlight-2) 50%,
var(--color-assistant-highlight-1) 100%
);
--color-assistant-inner-highlight-hover: linear-gradient(
108.82deg,
rgba(236, 123, 142, 0.12) 0%,
rgba(170, 123, 236, 0.12) 50.5%,
rgba(91, 96, 232, 0.12) 100%
);
--color-assistant-inner-highlight-active: linear-gradient(
108.82deg,
rgba(236, 123, 142, 0.25) 0%,
rgba(170, 123, 236, 0.25) 50.5%,
rgba(91, 96, 232, 0.25) 100%
);
// NodeIcon
--color-node-icon-gray: var(--prim-gray-420);
--color-node-icon-black: var(--prim-gray-780);

View file

@ -28,6 +28,10 @@
left: 16px;
}
&.content-toast {
position: absolute;
}
@include mixins.e(group) {
margin-left: var.$notification-group-margin-left;
margin-right: var.$notification-group-margin-right;

View file

@ -23,4 +23,27 @@ export default {
'You can style with <a href="https://docs.n8n.io/workflows/sticky-notes/" target="_blank">Markdown</a>',
'tags.showMore': (count: number) => `+${count} more`,
'datatable.pageSize': 'Page size',
'codeDiff.couldNotReplace': 'Could not replace code',
'codeDiff.codeReplaced': 'Code replaced',
'codeDiff.replaceMyCode': 'Replace my code',
'codeDiff.replacing': 'Replacing...',
'codeDiff.undo': 'Undo',
'betaTag.beta': 'beta',
'askAssistantButton.askAssistant': 'Ask Assistant',
'assistantChat.errorParsingMarkdown': 'Error parsing markdown content',
'assistantChat.aiAssistantLabel': 'AI Assistant',
'assistantChat.aiAssistantName': 'Ava',
'assistantChat.sessionEndMessage.1':
'This Assistant session has ended. To start a new session with the Assistant, click an',
'assistantChat.sessionEndMessage.2': 'button in n8n',
'assistantChat.you': 'You',
'assistantChat.quickRepliesTitle': 'Quick reply 👇',
'assistantChat.placeholder.1': (options: string[]) =>
`Hi ${options[0][0] || 'there'}, I'm ${options[0][1]} and I'm here to assist you with building workflows.`,
'assistantChat.placeholder.2':
"Whenever you encounter a task that I can help with, you'll see the",
'assistantChat.placeholder.3': 'button.',
'assistantChat.placeholder.4': 'Clicking it starts a chat session with me.',
'assistantChat.inputPlaceholder': 'Enter your response...',
'inlineAskAssistantButton.asked': 'Asked',
} as N8nLocale;

View file

@ -0,0 +1,69 @@
export namespace ChatUI {
export interface TextMessage {
role: 'assistant' | 'user';
type: 'text';
content: string;
}
export interface SummaryBlock {
role: 'assistant';
type: 'block';
title: string;
content: string;
}
interface CodeDiffMessage {
role: 'assistant';
type: 'code-diff';
description?: string;
codeDiff?: string;
replacing?: boolean;
replaced?: boolean;
error?: boolean;
suggestionId: string;
}
interface EndSessionMessage {
role: 'assistant';
type: 'event';
eventName: 'end-session';
}
export interface QuickReply {
type: string;
text: string;
}
export interface ErrorMessage {
role: 'assistant';
type: 'error';
content: string;
}
export interface AgentSuggestionMessage {
role: 'assistant';
type: 'agent-suggestion';
title: string;
content: string;
suggestionId: string;
}
type MessagesWithReplies = (
| TextMessage
| CodeDiffMessage
| SummaryBlock
| AgentSuggestionMessage
) & {
quickReplies?: QuickReply[];
};
export type AssistantMessage = (
| MessagesWithReplies
| ErrorMessage
| EndSessionMessage
| AgentSuggestionMessage
) & {
id: string;
read: boolean;
};
}

View file

@ -6,7 +6,7 @@ export type ButtonElement = (typeof BUTTON_ELEMENT)[number];
const BUTTON_TYPE = ['primary', 'secondary', 'tertiary', 'success', 'warning', 'danger'] as const;
export type ButtonType = (typeof BUTTON_TYPE)[number];
const BUTTON_SIZE = ['small', 'medium', 'large'] as const;
const BUTTON_SIZE = ['mini', 'small', 'medium', 'large'] as const;
export type ButtonSize = (typeof BUTTON_SIZE)[number];
const BUTTON_NATIVE_TYPE = ['submit', 'reset', 'button'] as const;

View file

@ -26,8 +26,10 @@
<component :is="Component" v-else />
</router-view>
</div>
<AskAssistantChat />
<Modals />
<Telemetry />
<AskAssistantFloatingButton v-if="showAssistantButton" />
</div>
</div>
</template>
@ -40,6 +42,8 @@ import BannerStack from '@/components/banners/BannerStack.vue';
import Modals from '@/components/Modals.vue';
import LoadingView from '@/views/LoadingView.vue';
import Telemetry from '@/components/Telemetry.vue';
import AskAssistantChat from '@/components/AskAssistant/AskAssistantChat.vue';
import AskAssistantFloatingButton from '@/components/AskAssistant/AskAssistantFloatingButton.vue';
import { HIRING_BANNER, VIEWS } from '@/constants';
import { loadLanguage } from '@/plugins/i18n';
@ -57,6 +61,7 @@ import { useUsageStore } from '@/stores/usage.store';
import { useUsersStore } from '@/stores/users.store';
import { useHistoryHelper } from '@/composables/useHistoryHelper';
import { useRoute } from 'vue-router';
import { useAssistantStore } from './stores/assistant.store';
export default defineComponent({
name: 'App',
@ -65,6 +70,8 @@ export default defineComponent({
LoadingView,
Telemetry,
Modals,
AskAssistantChat,
AskAssistantFloatingButton,
},
setup() {
return {
@ -76,6 +83,7 @@ export default defineComponent({
},
computed: {
...mapStores(
useAssistantStore,
useNodeTypesStore,
useRootStore,
useSettingsStore,
@ -92,6 +100,9 @@ export default defineComponent({
isDemoMode(): boolean {
return this.$route.name === VIEWS.DEMO;
},
showAssistantButton(): boolean {
return this.assistantStore.canShowAssistantButtons;
},
},
data() {
return {
@ -127,10 +138,10 @@ export default defineComponent({
.container {
display: grid;
grid-template-areas:
'banners banners'
'sidebar header'
'sidebar content';
grid-auto-columns: fit-content($sidebar-expanded-width) 1fr;
'banners banners rightsidebar'
'sidebar header rightsidebar'
'sidebar content rightsidebar';
grid-auto-columns: minmax(0, max-content) minmax(100px, auto) minmax(0, max-content);
grid-template-rows: auto fit-content($header-height) 1fr;
height: 100vh;
}
@ -143,6 +154,7 @@ export default defineComponent({
.content {
display: flex;
grid-area: content;
position: relative;
overflow: auto;
height: 100%;
width: 100%;

View file

@ -121,4 +121,7 @@ export const defaultSettings: IN8nUISettings = {
security: {
blockFileAccessToN8nFiles: false,
},
aiAssistant: {
enabled: false,
},
};

View file

@ -0,0 +1,25 @@
import type { IRestApiContext } from '@/Interface';
import type { ChatRequest, ReplaceCodeRequest } from '@/types/assistant.types';
import { makeRestApiRequest, streamRequest } from '@/utils/apiUtils';
export function chatWithAssistant(
ctx: IRestApiContext,
payload: ChatRequest.RequestPayload,
onMessageUpdated: (data: ChatRequest.ResponsePayload) => void,
onDone: () => void,
onError: (e: Error) => void,
): void {
void streamRequest(ctx, '/ai-assistant/chat', payload, onMessageUpdated, onDone, onError);
}
export async function replaceCode(
context: IRestApiContext,
data: ReplaceCodeRequest.RequestPayload,
): Promise<ReplaceCodeRequest.ResponsePayload> {
return await makeRestApiRequest<ReplaceCodeRequest.ResponsePayload>(
context,
'POST',
'/ai-assistant/chat/apply-suggestion',
data,
);
}

View file

@ -0,0 +1,105 @@
<script lang="ts" setup>
import { useAssistantStore } from '@/stores/assistant.store';
import { useDebounce } from '@/composables/useDebounce';
import { useUsersStore } from '@/stores/users.store';
import { computed } from 'vue';
import SlideTransition from '@/components/transitions/SlideTransition.vue';
import AskAssistantChat from 'n8n-design-system/components/AskAssistantChat/AskAssistantChat.vue';
import { useTelemetry } from '@/composables/useTelemetry';
const assistantStore = useAssistantStore();
const usersStore = useUsersStore();
const telemetry = useTelemetry();
const user = computed(() => ({
firstName: usersStore.currentUser?.firstName ?? '',
lastName: usersStore.currentUser?.lastName ?? '',
}));
function onResize(data: { direction: string; x: number; width: number }) {
assistantStore.updateWindowWidth(data.width);
}
function onResizeDebounced(data: { direction: string; x: number; width: number }) {
void useDebounce().callDebounced(onResize, { debounceTime: 10, trailing: true }, data);
}
async function onUserMessage(content: string, quickReplyType?: string) {
await assistantStore.sendMessage({ text: content, quickReplyType });
const task = 'error';
const solutionCount =
task === 'error'
? assistantStore.chatMessages.filter(
(msg) => msg.role === 'assistant' && !['text', 'event'].includes(msg.type),
).length
: null;
if (quickReplyType === 'all-good' || quickReplyType === 'still-stuck') {
telemetry.track('User gave feedback', {
task,
is_quick_reply: !!quickReplyType,
is_positive: quickReplyType === 'all-good',
solution_count: solutionCount,
response: content,
});
}
}
async function onCodeReplace(index: number) {
await assistantStore.applyCodeDiff(index);
telemetry.track('User clicked solution card action', {
action: 'replace_code',
});
}
async function undoCodeDiff(index: number) {
await assistantStore.undoCodeDiff(index);
telemetry.track('User clicked solution card action', {
action: 'undo_code_replace',
});
}
function onClose() {
assistantStore.closeChat();
telemetry.track('User closed assistant', { source: 'top-toggle' });
}
</script>
<template>
<SlideTransition>
<n8n-resize-wrapper
v-if="assistantStore.isAssistantOpen"
:supported-directions="['left']"
:width="assistantStore.chatWidth"
:class="$style.container"
@resize="onResizeDebounced"
>
<div
:style="{ width: `${assistantStore.chatWidth}px` }"
:class="$style.wrapper"
data-test-id="ask-assistant-chat"
>
<AskAssistantChat
:user="user"
:messages="assistantStore.chatMessages"
:streaming="assistantStore.streaming"
@close="onClose"
@message="onUserMessage"
@code-replace="onCodeReplace"
@code-undo="undoCodeDiff"
/>
</div>
</n8n-resize-wrapper>
</SlideTransition>
</template>
<style module>
.container {
grid-area: rightsidebar;
height: 100%;
z-index: 3000; /* Above NDV, below notifications */
}
.wrapper {
height: 100%;
}
</style>

View file

@ -0,0 +1,98 @@
<script setup lang="ts">
import { useI18n } from '@/composables/useI18n';
import { useTelemetry } from '@/composables/useTelemetry';
import { useAssistantStore } from '@/stores/assistant.store';
import { useWorkflowsStore } from '@/stores/workflows.store';
import AssistantAvatar from 'n8n-design-system/components/AskAssistantAvatar/AssistantAvatar.vue';
import AskAssistantButton from 'n8n-design-system/components/AskAssistantButton/AskAssistantButton.vue';
import { computed } from 'vue';
const assistantStore = useAssistantStore();
const i18n = useI18n();
const telemetry = useTelemetry();
const workflowStore = useWorkflowsStore();
const lastUnread = computed(() => {
const msg = assistantStore.lastUnread;
if (msg?.type === 'block') {
return msg.title;
}
if (msg?.type === 'text') {
return msg.content;
}
if (msg?.type === 'code-diff') {
return msg.description;
}
return '';
});
const onClick = () => {
assistantStore.openChat();
telemetry.track(
'User opened assistant',
{
source: 'canvas',
task: 'placeholder',
has_existing_session: !assistantStore.isSessionEnded,
workflow_id: workflowStore.workflowId,
},
{ withPostHog: true },
);
};
</script>
<template>
<div
v-if="assistantStore.canShowAssistantButtons && !assistantStore.isAssistantOpen"
:class="$style.container"
>
<n8n-tooltip
:z-index="4000"
placement="top"
:visible="!!lastUnread"
:popper-class="$style.tooltip"
>
<template #content>
<div :class="$style.text">{{ lastUnread }}</div>
<div :class="$style.assistant">
<AssistantAvatar size="mini" />
<span>{{ i18n.baseText('aiAssistant.name') }}</span>
</div>
</template>
<AskAssistantButton :unread-count="assistantStore.unreadCount" @click="onClick" />
</n8n-tooltip>
</div>
</template>
<style lang="scss" module>
.container {
position: absolute;
bottom: var(--spacing-s);
right: var(--spacing-s);
z-index: 3000;
}
.tooltip {
min-width: 150px;
max-width: 265px !important;
line-height: normal;
}
.assistant {
font-size: var(--font-size-3xs);
line-height: var(--spacing-s);
font-weight: var(--font-weight-bold);
margin-top: var(--spacing-xs);
> span {
margin-left: var(--spacing-4xs);
}
}
.text {
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 2; /* number of lines to show */
line-clamp: 2;
-webkit-box-orient: vertical;
}
</style>

View file

@ -0,0 +1,98 @@
<script lang="ts" setup>
import { NEW_ASSISTANT_SESSION_MODAL } from '@/constants';
import Modal from '../Modal.vue';
import AssistantIcon from 'n8n-design-system/components/AskAssistantIcon/AssistantIcon.vue';
import AssistantText from 'n8n-design-system/components/AskAssistantText/AssistantText.vue';
import { useI18n } from '@/composables/useI18n';
import { useUIStore } from '@/stores/ui.store';
import type { ChatRequest } from '@/types/assistant.types';
import { useAssistantStore } from '@/stores/assistant.store';
import { useTelemetry } from '@/composables/useTelemetry';
import { useWorkflowsStore } from '@/stores/workflows.store';
const i18n = useI18n();
const uiStore = useUIStore();
const assistantStore = useAssistantStore();
const workflowsStore = useWorkflowsStore();
const telemetry = useTelemetry();
const props = defineProps<{
name: string;
data: {
context: ChatRequest.ErrorContext;
};
}>();
const close = () => {
uiStore.closeModal(NEW_ASSISTANT_SESSION_MODAL);
};
const startNewSession = async () => {
await assistantStore.initErrorHelper(props.data.context);
telemetry.track(
'User opened assistant',
{
source: 'error',
task: 'error',
has_existing_session: true,
workflow_id: workflowsStore.workflowId,
node_type: props.data.context.node.type,
error: props.data.context.error,
chat_session_id: assistantStore.currentSessionId,
},
{ withPostHog: true },
);
close();
};
</script>
<template>
<Modal width="460px" height="250px" :name="NEW_ASSISTANT_SESSION_MODAL" :center="true">
<template #header>
{{ i18n.baseText('aiAssistant.newSessionModal.title.part1') }}
<span :class="$style.assistantIcon"><AssistantIcon size="medium" /></span>
<AssistantText size="xlarge" :text="i18n.baseText('aiAssistant.assistant')" />
{{ i18n.baseText('aiAssistant.newSessionModal.title.part2') }}
</template>
<template #content>
<div :class="$style.container">
<p>
<n8n-text>{{ i18n.baseText('aiAssistant.newSessionModal.message') }}</n8n-text>
</p>
<p>
<n8n-text>{{ i18n.baseText('aiAssistant.newSessionModal.question') }}</n8n-text>
</p>
</div>
</template>
<template #footer>
<div :class="$style.footer">
<n8n-button :label="$locale.baseText('generic.cancel')" type="secondary" @click="close" />
<n8n-button
:label="$locale.baseText('aiAssistant.newSessionModal.confirm')"
@click="startNewSession"
/>
</div>
</template>
</Modal>
</template>
<style lang="scss" module>
.container {
p {
line-height: normal;
}
p + p {
margin-top: 10px;
}
}
.assistantIcon {
margin-right: var(--spacing-4xs);
}
.footer {
display: flex;
gap: 10px;
justify-content: flex-end;
}
</style>

View file

@ -94,8 +94,8 @@ onBeforeUnmount(() => {
<style lang="scss" module>
.zoomMenu {
position: absolute;
bottom: var(--spacing-l);
left: var(--spacing-l);
bottom: var(--spacing-s);
left: var(--spacing-s);
line-height: 25px;
color: #444;
padding-right: 5px;

View file

@ -122,6 +122,8 @@ const telemetry = useTelemetry();
onMounted(() => {
if (!props.isReadOnly) codeNodeEditorEventBus.on('error-line-number', highlightLine);
codeNodeEditorEventBus.on('codeDiffApplied', diffApplied);
const { isReadOnly, language } = props;
const extensions: Extension[] = [
...readOnlyEditorExtensions,
@ -187,6 +189,7 @@ onMounted(() => {
});
onBeforeUnmount(() => {
codeNodeEditorEventBus.off('codeDiffApplied', diffApplied);
if (!props.isReadOnly) codeNodeEditorEventBus.off('error-line-number', highlightLine);
});
@ -214,6 +217,22 @@ const languageExtensions = computed<[LanguageSupport, ...Extension[]]>(() => {
}
});
watch(
() => props.modelValue,
(newValue) => {
if (!editor.value) {
return;
}
const current = editor.value.state.doc.toString();
if (current === newValue) {
return;
}
editor.value.dispatch({
changes: { from: 0, to: getCurrentEditorContent().length, insert: newValue },
});
},
);
watch(
() => props.mode,
(_newMode, previousMode: CodeExecutionMode) => {
@ -331,6 +350,13 @@ function getLine(lineNumber: number): Line | null {
}
}
function diffApplied() {
codeNodeEditorContainerRef.value?.classList.add('flash-editor');
codeNodeEditorContainerRef.value?.addEventListener('animationend', () => {
codeNodeEditorContainerRef.value?.classList.remove('flash-editor');
});
}
function highlightLine(lineNumber: number | 'final') {
if (!editor.value) return;
@ -399,6 +425,25 @@ function onAiLoadEnd() {
border: 0;
}
}
@keyframes backgroundAnimation {
0% {
background-color: none;
}
30% {
background-color: rgba(41, 163, 102, 0.1);
}
100% {
background-color: none;
}
}
.flash-editor {
:deep(.cm-editor),
:deep(.cm-gutter) {
animation: backgroundAnimation 1.5s ease-in-out;
}
}
</style>
<style lang="scss" module>

View file

@ -16,10 +16,18 @@ import type {
NodeOperationError,
} from 'n8n-workflow';
import { sanitizeHtml } from '@/utils/htmlUtils';
import { MAX_DISPLAY_DATA_SIZE } from '@/constants';
import { MAX_DISPLAY_DATA_SIZE, NEW_ASSISTANT_SESSION_MODAL } from '@/constants';
import type { BaseTextKey } from '@/plugins/i18n';
import { useAssistantStore } from '@/stores/assistant.store';
import type { ChatRequest } from '@/types/assistant.types';
import InlineAskAssistantButton from 'n8n-design-system/components/InlineAskAssistantButton/InlineAskAssistantButton.vue';
import { useUIStore } from '@/stores/ui.store';
import { isCommunityPackageName } from '@/utils/nodeTypesUtils';
import { useTelemetry } from '@/composables/useTelemetry';
import { useWorkflowsStore } from '@/stores/workflows.store';
type Props = {
// TODO: .node can be undefined
error: NodeError | NodeApiError | NodeOperationError;
compact?: boolean;
};
@ -32,17 +40,24 @@ const i18n = useI18n();
const nodeTypesStore = useNodeTypesStore();
const ndvStore = useNDVStore();
const rootStore = useRootStore();
const assistantStore = useAssistantStore();
const workflowsStore = useWorkflowsStore();
const uiStore = useUIStore();
const telemetry = useTelemetry();
const displayCause = computed(() => {
return JSON.stringify(props.error.cause ?? '').length < MAX_DISPLAY_DATA_SIZE;
});
const node = computed(() => {
return props.error.node || ndvStore.activeNode;
});
const parameters = computed<INodeProperties[]>(() => {
const node = ndvStore.activeNode;
if (!node) {
if (!node.value) {
return [];
}
const nodeType = nodeTypesStore.getNodeType(node.type, node.typeVersion);
const nodeType = nodeTypesStore.getNodeType(node.value.type, node.value.typeVersion);
if (nodeType === null) {
return [];
@ -67,13 +82,12 @@ const hasManyInputItems = computed(() => {
});
const nodeDefaultName = computed(() => {
const node = props.error?.node;
if (!node) {
if (!node.value) {
return 'Node';
}
const nodeType = nodeTypesStore.getNodeType(node.type, node.typeVersion);
return nodeType?.defaults?.name || node.name;
const nodeType = nodeTypesStore.getNodeType(node.value.type, node.value.typeVersion);
return nodeType?.defaults?.name || node.value.name;
});
const prepareRawMessages = computed(() => {
@ -104,6 +118,43 @@ const prepareRawMessages = computed(() => {
return returnData;
});
const isAskAssistantAvailable = computed(() => {
if (!node.value) {
return false;
}
const isCustomNode = node.value.type === undefined || isCommunityPackageName(node.value.type);
return assistantStore.canShowAssistantButtons && !isCustomNode;
});
const assistantAlreadyAsked = computed(() => {
return assistantStore.isNodeErrorActive({
error: simplifyErrorForAssistant(props.error),
node: props.error.node || ndvStore.activeNode,
});
});
function simplifyErrorForAssistant(
error: NodeError | NodeApiError | NodeOperationError,
): ChatRequest.ErrorContext['error'] {
const simple: ChatRequest.ErrorContext['error'] = {
name: error.name,
message: error.message,
};
if ('type' in error) {
simple.type = error.type;
}
if ('description' in error && error.description) {
simple.description = error.description;
}
if (error.stack) {
simple.stack = error.stack;
}
if ('lineNumber' in error) {
simple.lineNumber = error.lineNumber;
}
return simple;
}
function nodeVersionTag(nodeType: NodeError['node']): string {
if (!nodeType || ('hidden' in nodeType && nodeType.hidden)) {
return i18n.baseText('nodeSettings.deprecated');
@ -364,6 +415,41 @@ function copySuccess() {
type: 'info',
});
}
async function onAskAssistantClick() {
const { message, lineNumber, description } = props.error;
const sessionInProgress = !assistantStore.isSessionEnded;
const errorPayload: ChatRequest.ErrorContext = {
error: {
name: props.error.name,
message,
lineNumber,
description: description ?? getErrorDescription(),
type: 'type' in props.error ? props.error.type : undefined,
},
node: node.value,
};
if (sessionInProgress) {
uiStore.openModalWithData({
name: NEW_ASSISTANT_SESSION_MODAL,
data: { context: errorPayload },
});
return;
}
await assistantStore.initErrorHelper(errorPayload);
telemetry.track(
'User opened assistant',
{
source: 'error',
task: 'error',
has_existing_session: false,
workflow_id: workflowsStore.workflowId,
node_type: node.value.type,
error: props.error,
},
{ withPostHog: true },
);
}
</script>
<template>
@ -380,6 +466,9 @@ function copySuccess() {
class="node-error-view__header-description"
v-html="getErrorDescription()"
></div>
<div v-if="isAskAssistantAvailable" class="node-error-view__assistant-button">
<InlineAskAssistantButton :asked="assistantAlreadyAsked" @click="onAskAssistantClick" />
</div>
</div>
<div v-if="!compact" class="node-error-view__info">
@ -636,6 +725,11 @@ function copySuccess() {
}
}
&__assistant-button {
margin-left: var(--spacing-s);
margin-bottom: var(--spacing-xs);
}
&__debugging {
font-size: var(--font-size-s);
@ -765,4 +859,8 @@ function copySuccess() {
}
}
}
.node-error-view__assistant-button {
margin-top: var(--spacing-xs);
}
</style>

View file

@ -14,6 +14,7 @@ import {
PERSONALIZATION_MODAL_KEY,
TAGS_MANAGER_MODAL_KEY,
NPS_SURVEY_MODAL_KEY,
NEW_ASSISTANT_SESSION_MODAL,
VERSIONS_MODAL_KEY,
WORKFLOW_ACTIVE_MODAL_KEY,
WORKFLOW_LM_CHAT_MODAL_KEY,
@ -64,6 +65,7 @@ import WorkflowHistoryVersionRestoreModal from '@/components/WorkflowHistory/Wor
import SetupWorkflowCredentialsModal from '@/components/SetupWorkflowCredentialsModal/SetupWorkflowCredentialsModal.vue';
import ProjectMoveResourceModal from '@/components/Projects/ProjectMoveResourceModal.vue';
import ProjectMoveResourceConfirmModal from '@/components/Projects/ProjectMoveResourceConfirmModal.vue';
import NewAssistantSessionModal from '@/components/AskAssistant/NewAssistantSessionModal.vue';
import PromptMfaCodeModal from './PromptMfaCodeModal/PromptMfaCodeModal.vue';
</script>
@ -250,5 +252,10 @@ import PromptMfaCodeModal from './PromptMfaCodeModal/PromptMfaCodeModal.vue';
/>
</template>
</ModalRoot>
<ModalRoot :name="NEW_ASSISTANT_SESSION_MODAL">
<template #default="{ modalName, data }">
<NewAssistantSessionModal :name="modalName" :data="data" />
</template>
</ModalRoot>
</div>
</template>

View file

@ -112,7 +112,7 @@ function nodeTypeSelected(nodeTypes: string[]) {
placement="left"
>
<n8n-icon-button
size="xlarge"
size="large"
icon="plus"
type="tertiary"
:class="$style.nodeCreatorPlus"
@ -173,24 +173,11 @@ function nodeTypeSelected(nodeTypes: string[]) {
.nodeCreatorButton {
position: absolute;
text-align: center;
top: var(--spacing-l);
right: var(--spacing-l);
top: var(--spacing-s);
right: var(--spacing-s);
pointer-events: all !important;
button {
border-color: var(--color-button-node-creator-border-font);
color: var(--color-button-node-creator-border-font);
&:hover {
color: var(--color-button-node-creator-hover-font);
border-color: var(--color-button-node-creator-hover-border);
background: var(--color-button-node-creator-background);
}
}
}
.nodeCreatorPlus {
border-width: 2px;
border-radius: var(--border-radius-base);
width: 36px;
height: 36px;
}

View file

@ -38,6 +38,7 @@ import NodesListPanel from './Panel/NodesListPanel.vue';
import { useCredentialsStore } from '@/stores/credentials.store';
import { useUIStore } from '@/stores/ui.store';
import { DRAG_EVENT_DATA_KEY } from '@/constants';
import { useAssistantStore } from '@/stores/assistant.store';
export interface Props {
active?: boolean;
@ -52,6 +53,7 @@ const emit = defineEmits<{
nodeTypeSelected: [value: string[]];
}>();
const uiStore = useUIStore();
const assistantStore = useAssistantStore();
const { setShowScrim, setActions, setMergeNodes } = useNodeCreatorStore();
const { generateMergedNodesAndActions } = useActionsGenerator();
@ -66,7 +68,8 @@ const showScrim = computed(() => useNodeCreatorStore().showScrim);
const viewStacksLength = computed(() => useViewStacks().viewStacks.length);
const nodeCreatorInlineStyle = computed(() => {
return { top: `${uiStore.bannersHeight + uiStore.headerHeight}px` };
const rightPosition = assistantStore.isAssistantOpen ? assistantStore.chatWidth : 0;
return { top: `${uiStore.bannersHeight + uiStore.headerHeight}px`, right: `${rightPosition}px` };
});
function onMouseUpOutside() {
if (state.mousedownInsideEvent) {

View file

@ -237,6 +237,7 @@ import { useExternalHooks } from '@/composables/useExternalHooks';
import { useNodeHelpers } from '@/composables/useNodeHelpers';
import { importCurlEventBus } from '@/event-bus';
import { useToast } from '@/composables/useToast';
import { ndvEventBus } from '@/event-bus';
export default defineComponent({
name: 'NodeSettings',
@ -480,10 +481,12 @@ export default defineComponent({
this.nodeHelpers.updateNodeParameterIssues(this.node as INodeUi, this.nodeType);
importCurlEventBus.on('setHttpNodeParameters', this.setHttpNodeParameters);
ndvEventBus.on('updateParameterValue', this.valueChanged);
},
beforeUnmount() {
this.eventBus?.off('openSettings', this.openSettings);
importCurlEventBus.off('setHttpNodeParameters', this.setHttpNodeParameters);
ndvEventBus.off('updateParameterValue', this.valueChanged);
},
methods: {
setHttpNodeParameters(parameters: NodeParameterValueType) {

View file

@ -33,6 +33,7 @@ async function onCloseClick() {
</script>
<template>
<n8n-callout
:class="$style.callout"
:theme="props.theme"
:icon="props.customIcon"
icon-size="medium"
@ -60,6 +61,10 @@ async function onCloseClick() {
</template>
<style lang="scss" module>
.callout {
height: calc(var(--header-height) * 1px);
}
.mainContent {
display: flex;
gap: var(--spacing-4xs);

View file

@ -3,7 +3,7 @@
exports[`V1 Banner > should render banner 1`] = `
<div>
<n8n-callout
class="v1container"
class="callout v1container"
data-test-id="banners-V1"
icon="info-circle"
icon-size="medium"
@ -34,7 +34,7 @@ exports[`V1 Banner > should render banner 1`] = `
exports[`V1 Banner > should render banner with dismiss call if user is owner 1`] = `
<div>
<n8n-callout
class="v1container"
class="callout v1container"
data-test-id="banners-V1"
icon="info-circle"
icon-size="medium"

View file

@ -41,6 +41,7 @@ import { useWorkflowHelpers } from '@/composables/useWorkflowHelpers';
import { useI18n } from '@/composables/useI18n';
import { useTelemetry } from '@/composables/useTelemetry';
import type { PushMessageQueueItem } from '@/types';
import { useAssistantStore } from '@/stores/assistant.store';
export function usePushConnection({ router }: { router: ReturnType<typeof useRouter> }) {
const workflowHelpers = useWorkflowHelpers({ router });
@ -57,6 +58,7 @@ export function usePushConnection({ router }: { router: ReturnType<typeof useRou
const settingsStore = useSettingsStore();
const uiStore = useUIStore();
const workflowsStore = useWorkflowsStore();
const assistantStore = useAssistantStore();
const retryTimeout = ref<NodeJS.Timeout | null>(null);
const pushMessageQueue = ref<PushMessageQueueItem[]>([]);
@ -535,6 +537,7 @@ export function usePushConnection({ router }: { router: ReturnType<typeof useRou
const pushData = receivedData.data;
workflowsStore.addNodeExecutionData(pushData);
workflowsStore.removeExecutingNode(pushData.nodeName);
void assistantStore.onNodeExecution(pushData);
} else if (receivedData.type === 'nodeExecuteBefore') {
// A node started to be executed. Set it as executing.
const pushData = receivedData.data;

View file

@ -20,6 +20,10 @@ export interface NotificationErrorWithNodeAndDescription extends ApplicationErro
const messageDefaults: Partial<Omit<NotificationOptions, 'message'>> = {
dangerouslyUseHTMLString: false,
position: 'bottom-right',
zIndex: 3000, // above NDV and chat window
offset: 64,
appendTo: '#node-view-root',
customClass: 'content-toast',
};
const stickyNotificationQueue: NotificationHandle[] = [];
@ -154,7 +158,9 @@ export function useToast() {
}
function showAlert(config: NotificationOptions): NotificationHandle {
return Notification(config);
return Notification({
...config,
});
}
function causedByCredential(message: string | undefined) {

View file

@ -65,7 +65,7 @@ export const WORKFLOW_HISTORY_VERSION_RESTORE = 'workflowHistoryVersionRestore';
export const SETUP_CREDENTIALS_MODAL_KEY = 'setupCredentials';
export const PROJECT_MOVE_RESOURCE_MODAL = 'projectMoveResourceModal';
export const PROJECT_MOVE_RESOURCE_CONFIRM_MODAL = 'projectMoveResourceConfirmModal';
export const NEW_ASSISTANT_SESSION_MODAL = 'newAssistantSession';
export const EXTERNAL_SECRETS_PROVIDER_MODAL_KEY = 'externalSecretsProvider';
export const COMMUNITY_PACKAGE_MANAGE_ACTIONS = {
@ -628,6 +628,7 @@ export const enum STORES {
CLOUD_PLAN = 'cloudPlan',
RBAC = 'rbac',
PUSH = 'push',
ASSISTANT = 'assistant',
BECOME_TEMPLATE_CREATOR = 'becomeTemplateCreator',
}
@ -668,10 +669,17 @@ export const CANVAS_AUTO_ADD_MANUAL_TRIGGER_EXPERIMENT = {
variant: 'variant',
};
export const AI_ASSISTANT_EXPERIMENT = {
name: '021_ai_debug_helper',
control: 'control',
variant: 'variant',
};
export const EXPERIMENTS_TO_TRACK = [
ASK_AI_EXPERIMENT.name,
TEMPLATE_CREDENTIAL_SETUP_EXPERIMENT,
CANVAS_AUTO_ADD_MANUAL_TRIGGER_EXPERIMENT.name,
AI_ASSISTANT_EXPERIMENT.name,
];
export const MFA_FORM = {

View file

@ -131,6 +131,16 @@
"auth.signup.setupYourAccount": "Set up your account",
"auth.signup.setupYourAccountError": "Problem setting up your account",
"auth.signup.tokenValidationError": "Issue validating invite token",
"aiAssistant.name": "Ava",
"aiAssistant.assistant": "AI Assistant",
"aiAssistant.newSessionModal.title.part1": "Start new",
"aiAssistant.newSessionModal.title.part2": "session",
"aiAssistant.newSessionModal.message": "You already have an active AI Assistant session. Starting a new session will clear your current conversation history.",
"aiAssistant.newSessionModal.question": "Are you sure you want to start a new session?",
"aiAssistant.newSessionModal.confirm": "Start new session",
"aiAssistant.serviceError.message": "Unable to connect to n8n's AI service",
"aiAssistant.codeUpdated.message.title": "Ava modified workflow",
"aiAssistant.codeUpdated.message.body": "Open the <a data-action='openNodeDetail' data-action-parameter-node='{nodeName}'>{nodeName}</a> node to see the changes",
"banners.confirmEmail.message.1": "To secure your account and prevent future access issues, please confirm your",
"banners.confirmEmail.message.2": "email address.",
"banners.confirmEmail.button": "Confirm email",

View file

@ -35,3 +35,16 @@ export const faXmark: IconDefinition = {
'M342.6 150.6c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0L192 210.7 86.6 105.4c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3L146.7 256 41.4 361.4c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0L192 301.3 297.4 406.6c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3L237.3 256 342.6 150.6z',
],
};
export const faRefresh: IconDefinition = {
prefix: 'fas' as IconPrefix,
iconName: 'refresh' as IconName,
icon: [
12,
13,
[],
'',
'M8.67188 3.64062C7.94531 2.96094 6.98438 2.5625 5.97656 2.5625C4.17188 2.58594 2.60156 3.82812 2.17969 5.53906C2.13281 5.67969 2.01562 5.75 1.89844 5.75H0.5625C0.375 5.75 0.234375 5.60938 0.28125 5.42188C0.773438 2.72656 3.14062 0.6875 6 0.6875C7.54688 0.6875 8.95312 1.32031 10.0078 2.30469L10.8516 1.46094C11.2031 1.10938 11.8125 1.36719 11.8125 1.85938V5C11.8125 5.32812 11.5547 5.5625 11.25 5.5625H8.08594C7.59375 5.5625 7.33594 4.97656 7.6875 4.625L8.67188 3.64062ZM0.75 7.4375H3.89062C4.38281 7.4375 4.64062 8.04688 4.28906 8.39844L3.30469 9.38281C4.03125 10.0625 4.99219 10.4609 6 10.4609C7.80469 10.4375 9.375 9.19531 9.79688 7.48438C9.84375 7.34375 9.96094 7.27344 10.0781 7.27344H11.4141C11.6016 7.27344 11.7422 7.41406 11.6953 7.60156C11.2031 10.2969 8.83594 12.3125 6 12.3125C4.42969 12.3125 3.02344 11.7031 1.96875 10.7188L1.125 11.5625C0.773438 11.9141 0.1875 11.6562 0.1875 11.1641V8C0.1875 7.69531 0.421875 7.4375 0.75 7.4375Z',
],
};

View file

@ -159,8 +159,9 @@ import {
faProjectDiagram,
faStream,
faPowerOff,
faPaperPlane,
} from '@fortawesome/free-solid-svg-icons';
import { faVariable, faXmark, faVault } from './custom';
import { faVariable, faXmark, faVault, faRefresh } from './custom';
import { faStickyNote } from '@fortawesome/free-regular-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome';
@ -331,6 +332,8 @@ export const FontAwesomePlugin: Plugin = {
addIcon(faXmark);
addIcon(faDownload);
addIcon(faPowerOff);
addIcon(faPaperPlane);
addIcon(faRefresh);
app.component('FontAwesomeIcon', FontAwesomeIcon);
},

View file

@ -0,0 +1,554 @@
import { chatWithAssistant, replaceCode } from '@/api/assistant';
import { VIEWS, EDITABLE_CANVAS_VIEWS, STORES, AI_ASSISTANT_EXPERIMENT } from '@/constants';
import type { ChatRequest } from '@/types/assistant.types';
import type { ChatUI } from 'n8n-design-system/types/assistant';
import { defineStore } from 'pinia';
import { computed, ref, watch } from 'vue';
import { useRootStore } from './root.store';
import { useUsersStore } from './users.store';
import { useRoute } from 'vue-router';
import { useSettingsStore } from './settings.store';
import { assert } from '@/utils/assert';
import { useWorkflowsStore } from './workflows.store';
import type { INodeParameters } from 'n8n-workflow';
import { deepCopy } from 'n8n-workflow';
import { ndvEventBus, codeNodeEditorEventBus } from '@/event-bus';
import { useNDVStore } from './ndv.store';
import type { IPushDataNodeExecuteAfter, IUpdateInformation } from '@/Interface';
import {
getMainAuthField,
getNodeAuthOptions,
getReferencedNodes,
getNodesSchemas,
pruneNodeProperties,
} from '@/utils/nodeTypesUtils';
import { useNodeTypesStore } from './nodeTypes.store';
import { usePostHog } from './posthog.store';
import { useI18n } from '@/composables/useI18n';
import { useTelemetry } from '@/composables/useTelemetry';
import { useToast } from '@/composables/useToast';
const MAX_CHAT_WIDTH = 425;
const MIN_CHAT_WIDTH = 250;
const ENABLED_VIEWS = [...EDITABLE_CANVAS_VIEWS, VIEWS.EXECUTION_PREVIEW];
const READABLE_TYPES = ['code-diff', 'text', 'block'];
export const useAssistantStore = defineStore(STORES.ASSISTANT, () => {
const chatWidth = ref<number>(325);
const settings = useSettingsStore();
const rootStore = useRootStore();
const chatMessages = ref<ChatUI.AssistantMessage[]>([]);
const chatWindowOpen = ref<boolean>(false);
const usersStore = useUsersStore();
const workflowsStore = useWorkflowsStore();
const route = useRoute();
const streaming = ref<boolean>();
const ndvStore = useNDVStore();
const { getVariant } = usePostHog();
const locale = useI18n();
const telemetry = useTelemetry();
const suggestions = ref<{
[suggestionId: string]: {
previous: INodeParameters;
suggested: INodeParameters;
};
}>({});
const chatSessionError = ref<ChatRequest.ErrorContext | undefined>();
const currentSessionId = ref<string | undefined>();
const currentSessionActiveExecutionId = ref<string | undefined>();
const currentSessionWorkflowId = ref<string | undefined>();
const lastUnread = ref<ChatUI.AssistantMessage | undefined>();
const isExperimentEnabled = computed(
() => getVariant(AI_ASSISTANT_EXPERIMENT.name) === AI_ASSISTANT_EXPERIMENT.variant,
);
const canShowAssistant = computed(
() =>
isExperimentEnabled.value &&
settings.isAiAssistantEnabled &&
ENABLED_VIEWS.includes(route.name as VIEWS),
);
const isSessionEnded = computed(() => {
const assistantMessages = chatMessages.value.filter((msg) => msg.role === 'assistant');
const lastAssistantMessage = assistantMessages[assistantMessages.length - 1];
const sessionExplicitlyEnded =
lastAssistantMessage?.type === 'event' && lastAssistantMessage?.eventName === 'end-session';
const sessionStarted = currentSessionId.value !== undefined;
return !sessionStarted || sessionExplicitlyEnded;
});
const isAssistantOpen = computed(() => canShowAssistant.value && chatWindowOpen.value);
const canShowAssistantButtons = computed(
() =>
isExperimentEnabled.value &&
settings.isAiAssistantEnabled &&
EDITABLE_CANVAS_VIEWS.includes(route.name as VIEWS),
);
const unreadCount = computed(
() =>
chatMessages.value.filter(
(msg) => READABLE_TYPES.includes(msg.type) && msg.role === 'assistant' && !msg.read,
).length,
);
watch(route, () => {
const activeWorkflowId = workflowsStore.workflowId;
if (!currentSessionId.value || currentSessionWorkflowId.value === activeWorkflowId) {
return;
}
resetAssistantChat();
});
function resetAssistantChat() {
clearMessages();
currentSessionId.value = undefined;
chatSessionError.value = undefined;
lastUnread.value = undefined;
currentSessionActiveExecutionId.value = undefined;
suggestions.value = {};
}
function closeChat() {
chatWindowOpen.value = false;
}
function openChat() {
chatWindowOpen.value = true;
chatMessages.value = chatMessages.value.map((msg) => ({ ...msg, read: true }));
}
function addAssistantMessages(assistantMessages: ChatRequest.MessageResponse[], id: string) {
const read = chatWindowOpen.value;
const messages = [...chatMessages.value].filter(
(msg) => !(msg.id === id && msg.role === 'assistant'),
);
// TODO: simplify
assistantMessages.forEach((msg) => {
if (msg.type === 'message') {
messages.push({
id,
type: 'text',
role: 'assistant',
content: msg.text,
quickReplies: msg.quickReplies,
read,
});
} else if (msg.type === 'code-diff') {
messages.push({
id,
role: 'assistant',
type: 'code-diff',
description: msg.description,
codeDiff: msg.codeDiff,
suggestionId: msg.suggestionId,
quickReplies: msg.quickReplies,
read,
});
} else if (msg.type === 'summary') {
messages.push({
id,
type: 'block',
role: 'assistant',
title: msg.title,
content: msg.content,
quickReplies: msg.quickReplies,
read,
});
} else if (msg.type === 'event') {
messages.push({
id,
type: 'event',
role: 'assistant',
eventName: msg.eventName,
read: true,
});
} else if (msg.type === 'agent-suggestion') {
messages.push({
id,
type: 'block',
role: 'assistant',
title: msg.title,
content: msg.text,
quickReplies: msg.quickReplies,
read,
});
}
});
chatMessages.value = messages;
}
function updateWindowWidth(width: number) {
chatWidth.value = Math.min(Math.max(width, MIN_CHAT_WIDTH), MAX_CHAT_WIDTH);
}
function isNodeErrorActive(context: ChatRequest.ErrorContext) {
const targetNode = context.node.name;
return (
workflowsStore.activeExecutionId === currentSessionActiveExecutionId.value &&
targetNode === chatSessionError.value?.node.name
);
}
function clearMessages() {
chatMessages.value = [];
}
function stopStreaming() {
streaming.value = false;
}
function addAssistantError(content: string, id: string) {
chatMessages.value.push({
id,
role: 'assistant',
type: 'error',
content,
read: true,
});
}
function addEmptyAssistantMessage(id: string) {
chatMessages.value.push({
id,
role: 'assistant',
type: 'text',
content: '',
read: false,
});
}
function addUserMessage(content: string, id: string) {
chatMessages.value.push({
id,
role: 'user',
type: 'text',
content,
read: true,
});
}
function handleServiceError(e: unknown, id: string) {
assert(e instanceof Error);
stopStreaming();
addAssistantError(`${locale.baseText('aiAssistant.serviceError.message')}: (${e.message})`, id);
}
function onEachStreamingMessage(response: ChatRequest.ResponsePayload, id: string) {
if (response.sessionId && !currentSessionId.value) {
currentSessionId.value = response.sessionId;
telemetry.track('Assistant session started', {
chat_session_id: currentSessionId.value,
task: 'error',
});
} else if (currentSessionId.value !== response.sessionId) {
return;
}
addAssistantMessages(response.messages, id);
}
function getRandomId() {
return `${Math.floor(Math.random() * 100000000)}`;
}
function onDoneStreaming(id: string) {
stopStreaming();
lastUnread.value = chatMessages.value.find(
(msg) =>
msg.id === id && !msg.read && msg.role === 'assistant' && READABLE_TYPES.includes(msg.type),
);
setTimeout(() => {
if (lastUnread.value?.id === id) {
lastUnread.value = undefined;
}
}, 4000);
}
async function initErrorHelper(context: ChatRequest.ErrorContext) {
const id = getRandomId();
if (chatSessionError.value) {
if (isNodeErrorActive(context)) {
// context has not changed
return;
}
}
resetAssistantChat();
chatSessionError.value = context;
currentSessionWorkflowId.value = workflowsStore.workflowId;
if (workflowsStore.activeExecutionId) {
currentSessionActiveExecutionId.value = workflowsStore.activeExecutionId;
}
// Get all referenced nodes and their schemas
const referencedNodeNames = getReferencedNodes(context.node);
const schemas = getNodesSchemas(referencedNodeNames);
// Get node credentials details for the ai assistant
const nodeType = useNodeTypesStore().getNodeType(context.node.type);
let authType = undefined;
if (nodeType) {
const authField = getMainAuthField(nodeType);
const credentialInUse = context.node.parameters[authField?.name ?? ''];
const availableAuthOptions = getNodeAuthOptions(nodeType);
authType = availableAuthOptions.find((option) => option.value === credentialInUse);
}
addEmptyAssistantMessage(id);
openChat();
streaming.value = true;
chatWithAssistant(
rootStore.restApiContext,
{
payload: {
role: 'user',
type: 'init-error-helper',
user: {
firstName: usersStore.currentUser?.firstName ?? '',
},
error: context.error,
node: pruneNodeProperties(context.node, ['position']),
executionSchema: schemas,
authType,
},
},
(msg) => onEachStreamingMessage(msg, id),
() => onDoneStreaming(id),
(e) => handleServiceError(e, id),
);
}
async function sendEvent(
eventName: ChatRequest.InteractionEventName,
error?: ChatRequest.ErrorContext['error'],
) {
if (isSessionEnded.value || streaming.value) {
return;
}
assert(currentSessionId.value);
const id = getRandomId();
addEmptyAssistantMessage(id);
streaming.value = true;
chatWithAssistant(
rootStore.restApiContext,
{
payload: {
role: 'user',
type: 'event',
eventName,
error,
},
sessionId: currentSessionId.value,
},
(msg) => onEachStreamingMessage(msg, id),
() => onDoneStreaming(id),
(e) => handleServiceError(e, id),
);
}
async function onNodeExecution(pushEvent: IPushDataNodeExecuteAfter) {
if (!chatSessionError.value || pushEvent.nodeName !== chatSessionError.value.node.name) {
return;
}
if (pushEvent.data.error) {
await sendEvent('node-execution-errored', pushEvent.data.error);
} else if (pushEvent.data.executionStatus === 'success') {
await sendEvent('node-execution-succeeded');
}
telemetry.track('User executed node after assistant suggestion', {
task: 'error',
chat_session_id: currentSessionId.value,
success: pushEvent.data.executionStatus === 'success',
});
}
async function sendMessage(
chatMessage: Pick<ChatRequest.UserChatMessage, 'text' | 'quickReplyType'>,
) {
if (isSessionEnded.value || streaming.value) {
return;
}
const id = getRandomId();
try {
addUserMessage(chatMessage.text, id);
addEmptyAssistantMessage(id);
streaming.value = true;
assert(currentSessionId.value);
chatWithAssistant(
rootStore.restApiContext,
{
payload: {
role: 'user',
type: 'message',
text: chatMessage.text,
quickReplyType: chatMessage.quickReplyType,
},
sessionId: currentSessionId.value,
},
(msg) => onEachStreamingMessage(msg, id),
() => onDoneStreaming(id),
(e) => handleServiceError(e, id),
);
} catch (e: unknown) {
// in case of assert
handleServiceError(e, id);
}
}
function updateParameters(nodeName: string, parameters: INodeParameters) {
if (ndvStore.activeNodeName === nodeName) {
Object.keys(parameters).forEach((key) => {
const update: IUpdateInformation = {
node: nodeName,
name: `parameters.${key}`,
value: parameters[key],
};
ndvEventBus.emit('updateParameterValue', update);
});
} else {
workflowsStore.setNodeParameters(
{
name: nodeName,
value: parameters,
},
true,
);
}
}
function getRelevantParameters(
parameters: INodeParameters,
keysToKeep: string[],
): INodeParameters {
return keysToKeep.reduce((accu: INodeParameters, key: string) => {
accu[key] = deepCopy(parameters[key]);
return accu;
}, {} as INodeParameters);
}
async function applyCodeDiff(index: number) {
const codeDiffMessage = chatMessages.value[index];
if (!codeDiffMessage || codeDiffMessage.type !== 'code-diff') {
throw new Error('No code diff to apply');
}
try {
assert(chatSessionError.value);
assert(currentSessionId.value);
codeDiffMessage.replacing = true;
const suggestionId = codeDiffMessage.suggestionId;
const currentWorkflow = workflowsStore.getCurrentWorkflow();
const activeNode = currentWorkflow.getNode(chatSessionError.value.node.name);
assert(activeNode);
const cached = suggestions.value[suggestionId];
if (cached) {
updateParameters(activeNode.name, cached.suggested);
} else {
const { parameters: suggested } = await replaceCode(rootStore.restApiContext, {
suggestionId: codeDiffMessage.suggestionId,
sessionId: currentSessionId.value,
});
suggestions.value[suggestionId] = {
previous: getRelevantParameters(activeNode.parameters, Object.keys(suggested)),
suggested,
};
updateParameters(activeNode.name, suggested);
}
codeDiffMessage.replaced = true;
codeNodeEditorEventBus.emit('codeDiffApplied');
checkIfNodeNDVIsOpen(activeNode.name);
} catch (e) {
console.error(e);
codeDiffMessage.error = true;
}
codeDiffMessage.replacing = false;
}
async function undoCodeDiff(index: number) {
const codeDiffMessage = chatMessages.value[index];
if (!codeDiffMessage || codeDiffMessage.type !== 'code-diff') {
throw new Error('No code diff to apply');
}
try {
assert(chatSessionError.value);
assert(currentSessionId.value);
codeDiffMessage.replacing = true;
const suggestionId = codeDiffMessage.suggestionId;
const suggestion = suggestions.value[suggestionId];
assert(suggestion);
const currentWorkflow = workflowsStore.getCurrentWorkflow();
const activeNode = currentWorkflow.getNode(chatSessionError.value.node.name);
assert(activeNode);
const suggested = suggestion.previous;
updateParameters(activeNode.name, suggested);
codeDiffMessage.replaced = false;
codeNodeEditorEventBus.emit('codeDiffApplied');
checkIfNodeNDVIsOpen(activeNode.name);
} catch (e) {
console.error(e);
codeDiffMessage.error = true;
}
codeDiffMessage.replacing = false;
}
function checkIfNodeNDVIsOpen(errorNodeName: string) {
if (errorNodeName !== ndvStore.activeNodeName) {
useToast().showMessage({
type: 'success',
title: locale.baseText('aiAssistant.codeUpdated.message.title'),
message: locale.baseText('aiAssistant.codeUpdated.message.body', {
interpolate: { nodeName: errorNodeName },
}),
dangerouslyUseHTMLString: true,
duration: 4000,
});
}
}
return {
chatWidth,
chatMessages,
unreadCount,
streaming,
isAssistantOpen,
canShowAssistant,
canShowAssistantButtons,
currentSessionId,
lastUnread,
isSessionEnded,
onNodeExecution,
closeChat,
openChat,
updateWindowWidth,
isNodeErrorActive,
initErrorHelper,
sendMessage,
applyCodeDiff,
undoCodeDiff,
resetAssistantChat,
};
});

View file

@ -88,6 +88,8 @@ export const useSettingsStore = defineStore(STORES.SETTINGS, () => {
const isSamlLoginEnabled = computed(() => saml.value.loginEnabled);
const isAiAssistantEnabled = computed(() => settings.value.aiAssistant?.enabled);
const showSetupPage = computed(() => userManagement.value.showSetupOnFirstLoad);
const deploymentType = computed(() => settings.value.deployment?.type || 'default');
@ -401,6 +403,7 @@ export const useSettingsStore = defineStore(STORES.SETTINGS, () => {
logLevel,
isTelemetryEnabled,
isMfaFeatureEnabled,
isAiAssistantEnabled,
areTagsEnabled,
isHiringBannerEnabled,
isTemplatesEnabled,

View file

@ -35,6 +35,7 @@ import {
SETUP_CREDENTIALS_MODAL_KEY,
PROJECT_MOVE_RESOURCE_MODAL,
PROJECT_MOVE_RESOURCE_CONFIRM_MODAL,
NEW_ASSISTANT_SESSION_MODAL,
PROMPT_MFA_CODE_MODAL_KEY,
} from '@/constants';
import type {
@ -124,6 +125,7 @@ export const useUIStore = defineStore(STORES.UI, () => {
SETUP_CREDENTIALS_MODAL_KEY,
PROJECT_MOVE_RESOURCE_MODAL,
PROJECT_MOVE_RESOURCE_CONFIRM_MODAL,
NEW_ASSISTANT_SESSION_MODAL,
].map((modalKey) => [modalKey, { open: false }]),
),
[DELETE_USER_MODAL_KEY]: {

View file

@ -0,0 +1,123 @@
import type { Schema } from '@/Interface';
import type { INode, INodeParameters } from 'n8n-workflow';
export namespace ChatRequest {
interface NodeExecutionSchema {
nodeName: string;
schema: Schema;
}
export interface WorkflowContext {
executionSchema?: NodeExecutionSchema[];
}
export interface ErrorContext {
error: {
name: string;
message: string;
type?: string;
description?: string | null;
lineNumber?: number;
stack?: string;
};
node: INode;
}
export interface InitErrorHelper extends ErrorContext, WorkflowContext {
role: 'user';
type: 'init-error-helper';
user: {
firstName: string;
};
authType?: { name: string; value: string };
}
export type InteractionEventName = 'node-execution-succeeded' | 'node-execution-errored';
interface EventRequestPayload {
role: 'user';
type: 'event';
eventName: InteractionEventName;
error?: ErrorContext['error'];
}
export interface UserChatMessage {
role: 'user';
type: 'message';
text: string;
quickReplyType?: string;
}
export type RequestPayload =
| {
payload: InitErrorHelper;
}
| {
payload: EventRequestPayload | UserChatMessage;
sessionId: string;
};
interface CodeDiffMessage {
role: 'assistant';
type: 'code-diff';
description?: string;
codeDiff?: string;
suggestionId: string;
solution_count: number;
}
interface QuickReplyOption {
text: string;
type: string;
isFeedback?: boolean;
}
interface AssistantChatMessage {
role: 'assistant';
type: 'message';
text: string;
}
interface AssistantSummaryMessage {
role: 'assistant';
type: 'summary';
title: string;
content: string;
}
interface EndSessionMessage {
role: 'assistant';
type: 'event';
eventName: 'end-session';
}
interface AgentChatMessage {
role: 'assistant';
type: 'agent-suggestion';
title: string;
text: string;
}
export type MessageResponse =
| ((AssistantChatMessage | CodeDiffMessage | AssistantSummaryMessage | AgentChatMessage) & {
quickReplies?: QuickReplyOption[];
})
| EndSessionMessage;
export interface ResponsePayload {
sessionId?: string;
messages: MessageResponse[];
}
}
export namespace ReplaceCodeRequest {
export interface RequestPayload {
sessionId: string;
suggestionId: string;
}
export interface ResponsePayload {
sessionId: string;
parameters: INodeParameters;
}
}

View file

@ -1,8 +1,9 @@
import type { AxiosRequestConfig, Method, RawAxiosRequestHeaders } from 'axios';
import axios from 'axios';
import { ApplicationError, type GenericValue, type IDataObject } from 'n8n-workflow';
import { ApplicationError, jsonParse, type GenericValue, type IDataObject } from 'n8n-workflow';
import type { IExecutionFlattedResponse, IExecutionResponse, IRestApiContext } from '@/Interface';
import { parse } from 'flatted';
import type { ChatRequest } from '@/types/assistant.types';
const BROWSER_ID_STORAGE_KEY = 'n8n-browserId';
let browserId = localStorage.getItem(BROWSER_ID_STORAGE_KEY);
@ -191,3 +192,63 @@ export function unflattenExecutionData(fullExecutionData: IExecutionFlattedRespo
return returnData;
}
export const streamRequest = async (
context: IRestApiContext,
apiEndpoint: string,
payload: ChatRequest.RequestPayload,
onChunk?: (chunk: ChatRequest.ResponsePayload) => void,
onDone?: () => void,
onError?: (e: Error) => void,
separator = '⧉⇋⇋➽⌑⧉§§\n',
): Promise<void> => {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
};
if (browserId) {
headers['browser-id'] = browserId;
}
const assistantRequest: RequestInit = {
headers,
method: 'POST',
credentials: 'include',
body: JSON.stringify(payload),
};
const response = await fetch(`${context.baseUrl}${apiEndpoint}`, assistantRequest);
if (response.ok && response.body) {
// Handle the streaming response
const reader = response.body.getReader();
const decoder = new TextDecoder('utf-8');
async function readStream() {
const { done, value } = await reader.read();
if (done) {
onDone?.();
return;
}
const chunk = decoder.decode(value);
const splitChunks = chunk.split(separator);
for (const splitChunk of splitChunks) {
if (splitChunk && onChunk) {
try {
onChunk(jsonParse(splitChunk, { errorMessage: 'Invalid json chunk in stream' }));
} catch (e: unknown) {
if (e instanceof Error) {
console.log(`${e.message}: ${splitChunk}`);
onError?.(e);
}
}
}
}
await readStream();
}
// Start reading the stream
await readStream();
} else if (onError) {
onError(new Error(response.statusText));
}
};

View file

@ -5,8 +5,10 @@ import type {
ITemplatesNode,
IVersionNode,
NodeAuthenticationOption,
Schema,
SimplifiedNodeType,
} from '@/Interface';
import { useDataSchema } from '@/composables/useDataSchema';
import {
CORE_NODES_CATEGORY,
MAIN_AUTH_FIELD_NAME,
@ -21,7 +23,9 @@ import { useWorkflowsStore } from '@/stores/workflows.store';
import { isResourceLocatorValue } from '@/utils/typeGuards';
import { isJsonKeyObject } from '@/utils/typesUtils';
import type {
AssignmentCollectionValue,
IDataObject,
INode,
INodeCredentialDescription,
INodeExecutionData,
INodeProperties,
@ -496,3 +500,101 @@ export const getNodeIconColor = (
}
return nodeType?.defaults?.color?.toString();
};
/**
Regular expression to extract the node names from the expressions in the template.
Example: $(expression) => expression
*/
const entityRegex = /\$\((['"])(.*?)\1\)/g;
/**
* Extract the node names from the expressions in the template.
*/
function extractNodeNames(template: string): string[] {
let matches;
const nodeNames: string[] = [];
while ((matches = entityRegex.exec(template)) !== null) {
nodeNames.push(matches[2]);
}
return nodeNames;
}
/**
* Extract the node names from the expressions in the node parameters.
*/
export function getReferencedNodes(node: INode): string[] {
const referencedNodes: string[] = [];
if (!node) {
return referencedNodes;
}
// Special case for code node
if (node.type === 'n8n-nodes-base.set' && node.parameters.assignments) {
const assignments = node.parameters.assignments as AssignmentCollectionValue;
if (assignments.assignments?.length) {
assignments.assignments.forEach((assignment) => {
if (assignment.name && assignment.value && String(assignment.value).startsWith('=')) {
const nodeNames = extractNodeNames(String(assignment.value));
if (nodeNames.length) {
referencedNodes.push(...nodeNames);
}
}
});
}
} else {
Object.values(node.parameters).forEach((value) => {
if (!value) {
return;
}
let strValue = String(value);
// Handle resource locator
if (typeof value === 'object' && 'value' in value) {
strValue = String(value.value);
}
if (strValue.startsWith('=')) {
const nodeNames = extractNodeNames(strValue);
if (nodeNames.length) {
referencedNodes.push(...nodeNames);
}
}
});
}
return referencedNodes;
}
/**
* Remove properties from a node based on the provided list of property names.
* Reruns a new node object with the properties removed.
*/
export function pruneNodeProperties(node: INode, propsToRemove: string[]): INode {
const prunedNode = { ...node };
propsToRemove.forEach((key) => {
delete prunedNode[key as keyof INode];
});
return prunedNode;
}
/**
* Get the schema for the referenced nodes as expected by the AI assistant
* @param nodeNames The names of the nodes to get the schema for
* @returns An array of objects containing the node name and the schema
*/
export function getNodesSchemas(nodeNames: string[]) {
return nodeNames.map((name) => {
const node = useWorkflowsStore().getNodeByName(name);
if (!node) {
return {
nodeName: name,
schema: {} as Schema,
};
}
const { getSchemaForExecutionData, getInputDataWithPinned } = useDataSchema();
const schema = getSchemaForExecutionData(
executionDataToJson(getInputDataWithPinned(node)),
true,
);
return {
nodeName: node.name,
schema,
};
});
}

View file

@ -4746,7 +4746,7 @@ export default defineComponent({
align-items: center;
left: 50%;
transform: translateX(-50%);
bottom: var(--spacing-l);
bottom: var(--spacing-s);
width: auto;
@media (max-width: $breakpoint-2xs) {

View file

@ -2679,6 +2679,9 @@ export interface IN8nUISettings {
executionMode: 'regular' | 'queue';
pushBackend: 'sse' | 'websocket';
communityNodesEnabled: boolean;
aiAssistant: {
enabled: boolean;
};
deployment: {
type: string | 'default' | 'n8n-internal' | 'cloud' | 'desktop_mac' | 'desktop_win';
};

View file

@ -629,6 +629,9 @@ importers:
'@n8n/typeorm':
specifier: 0.3.20-10
version: 0.3.20-10(@sentry/node@7.87.0)(ioredis@5.3.2)(mssql@10.0.2)(mysql2@3.11.0)(pg@8.12.0)(redis@4.6.14)(sqlite3@5.1.7)
'@n8n_io/ai-assistant-sdk':
specifier: 1.9.4
version: 1.9.4
'@n8n_io/license-sdk':
specifier: 2.13.0
version: 2.13.0
@ -1075,6 +1078,9 @@ importers:
markdown-it-task-lists:
specifier: ^2.1.1
version: 2.1.1
parse-diff:
specifier: ^0.11.1
version: 0.11.1
sanitize-html:
specifier: 2.12.1
version: 2.12.1
@ -3405,6 +3411,7 @@ packages:
'@humanwhocodes/config-array@0.11.14':
resolution: {integrity: sha512-3T8LkOmg45BV5FICb15QQMsyUSWrQ8AygVfC7ZG32zOalnqrilm018ZVCw0eapXux8FtA33q8PSRSstjee3jSg==}
engines: {node: '>=10.10.0'}
deprecated: Use @eslint/config-array instead
'@humanwhocodes/module-importer@1.0.1':
resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==}
@ -3412,6 +3419,7 @@ packages:
'@humanwhocodes/object-schema@2.0.2':
resolution: {integrity: sha512-6EwiSjwWYP7pTckG6I5eyFANjPhmPjUX9JRLUSfNPC7FX7zK9gyZAfUEaECL6ALTpGX5AjnBq3C9XmVWPitNpw==}
deprecated: Use @eslint/object-schema instead
'@icetee/ftp@0.3.15':
resolution: {integrity: sha512-RxSa9VjcDWgWCYsaLdZItdCnJj7p4LxggaEk+Y3MP0dHKoxez8ioG07DVekVbZZqccsrL+oPB/N9AzVPxj4blg==}
@ -4216,6 +4224,10 @@ packages:
engines: {node: '>=18.10', pnpm: '>=9.6'}
hasBin: true
'@n8n_io/ai-assistant-sdk@1.9.4':
resolution: {integrity: sha512-jFBT3SNPQuPTiJceCVIO0VB06ALa6Az1nfsRnZYdm+HMIle1ZFZDIjf32tqYoa2VvrqitfR/fs7CiMlVQUkIRg==}
engines: {node: '>=20.15', pnpm: '>=8.14'}
'@n8n_io/license-sdk@2.13.0':
resolution: {integrity: sha512-iVER9RjR6pP4ujceG7rSMoHU0IMI5HvwNHhC8ezq1VwbRq8W1ecYQpTbIrUTgK6gNMyeLRfySkNlVGejKXJ3MQ==}
engines: {node: '>=18.12.1'}
@ -5979,6 +5991,7 @@ packages:
abab@2.0.6:
resolution: {integrity: sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA==}
deprecated: Use your platform's native atob() and btoa() methods instead
abbrev@1.1.1:
resolution: {integrity: sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==}
@ -6130,6 +6143,7 @@ packages:
are-we-there-yet@3.0.1:
resolution: {integrity: sha512-QZW4EDmGwlYur0Yyf/b2uGucHQMa8aFUP7eu9ddR73vvhFyt4V0Vl3QHPcTNJ8l6qYOBdxgXdnBXQrHilfRQBg==}
engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0}
deprecated: This package is no longer supported.
arg@5.0.2:
resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==}
@ -7356,6 +7370,7 @@ packages:
domexception@4.0.0:
resolution: {integrity: sha512-A2is4PLG+eeSfoTMA95/s4pvAoSo2mKtiM5jlHkAVewmiO8ISFTFKZjH7UAM1Atli/OT/7JHOrJRJiMKUZKYBw==}
engines: {node: '>=12'}
deprecated: Use your platform's native DOMException instead
domhandler@4.3.1:
resolution: {integrity: sha512-GrwoxYN+uWlzO8uhUXRl0P+kHE4GtVPfYzVLcUxPL7KNdHKj66vvlhiweIHqYYXWlw+T8iLMp42Lm67ghw4WMQ==}
@ -8169,6 +8184,7 @@ packages:
gauge@4.0.4:
resolution: {integrity: sha512-f9m+BEN5jkg6a0fZjleidjN51VE1X+mPFQ2DJ0uv1V39oCLCbsGe6yjbBnp7eK7z/+GAon99a3nHuqbuuthyPg==}
engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0}
deprecated: This package is no longer supported.
gaxios@5.1.0:
resolution: {integrity: sha512-aezGIjb+/VfsJtIcHGcBSerNEDdfdHeMros+RbYbGpmonKWQCOVOes0LVZhn1lDtIgq55qq0HaxymIoae3Fl/A==}
@ -8300,10 +8316,12 @@ packages:
glob@7.2.3:
resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==}
deprecated: Glob versions prior to v9 are no longer supported
glob@8.1.0:
resolution: {integrity: sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==}
engines: {node: '>=12'}
deprecated: Glob versions prior to v9 are no longer supported
glob@9.3.2:
resolution: {integrity: sha512-BTv/JhKXFEHsErMte/AnfiSv8yYOLLiyH2lTg8vn02O21zWFgHPTfxtgn1QRe7NRgggUhC8hacR2Re94svHqeA==}
@ -8617,12 +8635,14 @@ packages:
infisical-node@1.3.0:
resolution: {integrity: sha512-tTnnExRAO/ZyqiRdnSlBisErNToYWgtunMWh+8opClEt5qjX7l6HC/b4oGo2AuR2Pf41IR+oqo+dzkM1TCvlUA==}
deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.
inflected@2.1.0:
resolution: {integrity: sha512-hAEKNxvHf2Iq3H60oMBHkB4wl5jn3TPF3+fXek/sRwAB5gP9xWs4r7aweSF95f99HFoz69pnZTcu8f0SIHV18w==}
inflight@1.0.6:
resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==}
deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.
inherits@2.0.4:
resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
@ -10362,6 +10382,7 @@ packages:
npmlog@6.0.2:
resolution: {integrity: sha512-/vBvz5Jfr9dT/aFWd0FIRf+T/Q2WBsLENygUaFUqstqsycmZAP/t5BvFJTK0viFmSUxiUKTUplWy5vt+rvKIxg==}
engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0}
deprecated: This package is no longer supported.
nth-check@2.1.1:
resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==}
@ -10602,6 +10623,9 @@ packages:
resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
engines: {node: '>=6'}
parse-diff@0.11.1:
resolution: {integrity: sha512-Oq4j8LAOPOcssanQkIjxosjATBIEJhCxMCxPhMu+Ci4wdNmAEdx0O+a7gzbR2PyKXgKPvRLIN5g224+dJAsKHA==}
parse-json@5.2.0:
resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==}
engines: {node: '>=8'}
@ -11476,10 +11500,12 @@ packages:
rimraf@2.6.3:
resolution: {integrity: sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA==}
deprecated: Rimraf versions prior to v4 are no longer supported
hasBin: true
rimraf@3.0.2:
resolution: {integrity: sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==}
deprecated: Rimraf versions prior to v4 are no longer supported
hasBin: true
rimraf@5.0.1:
@ -12542,6 +12568,10 @@ packages:
resolution: {integrity: sha512-/0BWqR8rJNRysS5lqVmfc7eeOErcOP4tZpATVjJOojjHZ71gSYVAtFhEmadcIjwMIUehh5NFyKGsXCnXIajtbA==}
engines: {node: '>=18.17'}
undici@6.19.7:
resolution: {integrity: sha512-HR3W/bMGPSr90i8AAp2C4DM3wChFdJPLrWYpIS++LxS8K+W535qftjt+4MyjNYHeWabMj1nvtmLIi7l++iq91A==}
engines: {node: '>=18.17'}
unicode-canonical-property-names-ecmascript@2.0.0:
resolution: {integrity: sha512-yY5PpDlfVIU5+y/BSCxAJRBIS1Zc2dDG3Ujq+sR0U+JjUevW2JhocOF+soROYDSaAezOzOKuyyixhD6mBknSmQ==}
engines: {node: '>=4'}
@ -14403,7 +14433,7 @@ snapshots:
'@babel/traverse': 7.24.0
'@babel/types': 7.24.6
convert-source-map: 2.0.0
debug: 4.3.5(supports-color@8.1.1)
debug: 4.3.6
gensync: 1.0.0-beta.2
json5: 2.2.3
semver: 7.6.0
@ -14524,7 +14554,7 @@ snapshots:
'@babel/core': 7.24.6
'@babel/helper-compilation-targets': 7.24.6
'@babel/helper-plugin-utils': 7.24.6
debug: 4.3.5(supports-color@8.1.1)
debug: 4.3.6
lodash.debounce: 4.0.8
resolve: 1.22.8
transitivePeerDependencies:
@ -14535,7 +14565,7 @@ snapshots:
'@babel/core': 7.24.6
'@babel/helper-compilation-targets': 7.24.6
'@babel/helper-plugin-utils': 7.24.6
debug: 4.3.5(supports-color@8.1.1)
debug: 4.3.6
lodash.debounce: 4.0.8
resolve: 1.22.8
transitivePeerDependencies:
@ -15419,7 +15449,7 @@ snapshots:
'@babel/helper-split-export-declaration': 7.22.6
'@babel/parser': 7.24.6
'@babel/types': 7.24.6
debug: 4.3.5(supports-color@8.1.1)
debug: 4.3.6
globals: 11.12.0
transitivePeerDependencies:
- supports-color
@ -16637,7 +16667,7 @@ snapshots:
buffer: 6.0.3
chalk: 4.1.2
dayjs: 1.11.10
debug: 4.3.4(supports-color@8.1.1)
debug: 4.3.6
dotenv: 16.3.1
glob: 10.3.10
mkdirp: 2.1.3
@ -16667,7 +16697,7 @@ snapshots:
buffer: 6.0.3
chalk: 4.1.2
dayjs: 1.11.10
debug: 4.3.4(supports-color@8.1.1)
debug: 4.3.6
dotenv: 16.3.1
glob: 10.3.10
mkdirp: 2.1.3
@ -16693,6 +16723,10 @@ snapshots:
acorn: 8.12.1
acorn-walk: 8.3.2
'@n8n_io/ai-assistant-sdk@1.9.4':
dependencies:
undici: 6.19.7
'@n8n_io/license-sdk@2.13.0':
dependencies:
crypto-js: 4.2.0
@ -18941,7 +18975,7 @@ snapshots:
dependencies:
'@typescript-eslint/types': 6.7.5
'@typescript-eslint/visitor-keys': 6.7.5
debug: 4.3.5(supports-color@8.1.1)
debug: 4.3.6
globby: 11.1.0
is-glob: 4.0.3
semver: 7.6.0
@ -22122,7 +22156,7 @@ snapshots:
dependencies:
foreground-child: 3.1.1
jackspeak: 2.3.6
minimatch: 9.0.3
minimatch: 9.0.5
minipass: 7.0.2
path-scurry: 1.10.1
@ -22431,7 +22465,7 @@ snapshots:
dependencies:
'@tootallnate/once': 1.1.2
agent-base: 6.0.2
debug: 4.3.5(supports-color@8.1.1)
debug: 4.3.6
transitivePeerDependencies:
- supports-color
optional: true
@ -22440,7 +22474,7 @@ snapshots:
dependencies:
'@tootallnate/once': 2.0.0
agent-base: 6.0.2
debug: 4.3.5(supports-color@8.1.1)
debug: 4.3.6
transitivePeerDependencies:
- supports-color
@ -22841,7 +22875,7 @@ snapshots:
istanbul-lib-source-maps@4.0.1:
dependencies:
debug: 4.3.5(supports-color@8.1.1)
debug: 4.3.6
istanbul-lib-coverage: 3.2.2
source-map: 0.6.1
transitivePeerDependencies:
@ -24793,6 +24827,8 @@ snapshots:
dependencies:
callsites: 3.1.0
parse-diff@0.11.1: {}
parse-json@5.2.0:
dependencies:
'@babel/code-frame': 7.24.6
@ -25741,7 +25777,7 @@ snapshots:
retry-request@5.0.2:
dependencies:
debug: 4.3.5(supports-color@8.1.1)
debug: 4.3.6
extend: 3.0.2
transitivePeerDependencies:
- supports-color
@ -26170,7 +26206,7 @@ snapshots:
socks-proxy-agent@6.2.1:
dependencies:
agent-base: 6.0.2
debug: 4.3.5(supports-color@8.1.1)
debug: 4.3.6
socks: 2.7.1
transitivePeerDependencies:
- supports-color
@ -27045,6 +27081,8 @@ snapshots:
undici@6.18.1: {}
undici@6.19.7: {}
unicode-canonical-property-names-ecmascript@2.0.0: {}
unicode-match-property-ecmascript@2.0.0: