mirror of
https://github.com/n8n-io/n8n.git
synced 2024-09-11 13:15:28 +03:00
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:
parent
e4c88e75f9
commit
5ed2a77740
@ -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');
|
||||
|
@ -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",
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -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';
|
||||
|
@ -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',
|
||||
|
@ -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;
|
||||
|
||||
|
44
packages/cli/src/controllers/aiAssistant.controller.ts
Normal file
44
packages/cli/src/controllers/aiAssistant.controller.ts
Normal 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}` });
|
||||
}
|
||||
}
|
||||
}
|
@ -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,
|
||||
};
|
||||
|
||||
|
@ -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>;
|
||||
}
|
||||
|
54
packages/cli/src/services/aiAsisstant.service.ts
Normal file
54
packages/cli/src/services/aiAsisstant.service.ts
Normal 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 });
|
||||
}
|
||||
}
|
@ -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');
|
||||
|
@ -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",
|
||||
|
@ -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',
|
||||
};
|
@ -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>
|
@ -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,
|
||||
};
|
@ -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>
|
@ -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 doesn’t have correct permissions to send a message',
|
||||
content:
|
||||
'Solution steps:\n1. Lorem ipsum dolor sit amet, consectetur **adipiscing** elit. Proin id nulla placerat, tristique ex at, euismod dui.\n2. Copy this into somewhere\n3. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Proin id nulla placerat, tristique ex at, euismod dui.\n4. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Proin id nulla placerat, tristique ex at, euismod dui. \n Testing more code \n - Unordered item 1 \n - Unordered item 2',
|
||||
read: false,
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
type: 'code-diff',
|
||||
role: 'assistant',
|
||||
description: 'Short solution with min height',
|
||||
codeDiff:
|
||||
'@@ -1,7 +1,6 @@\n-The Way that can be told of is not the eternal Way;\n-The name that can be named is not the eternal name.\n+The door of all subtleties!',
|
||||
quickReplies: [
|
||||
{
|
||||
type: 'new-suggestion',
|
||||
text: 'Give me another solution',
|
||||
},
|
||||
{
|
||||
type: 'resolved',
|
||||
text: 'All good',
|
||||
},
|
||||
],
|
||||
suggestionId: 'test',
|
||||
read: false,
|
||||
},
|
||||
]),
|
||||
};
|
||||
|
||||
export const JustSummary = Template.bind({});
|
||||
JustSummary.args = {
|
||||
user: {
|
||||
firstName: 'Max',
|
||||
lastName: 'Test',
|
||||
},
|
||||
messages: getMessages([
|
||||
{
|
||||
id: '123',
|
||||
role: 'assistant',
|
||||
type: 'block',
|
||||
title: 'Credential doesn’t have correct permissions to send a message',
|
||||
content:
|
||||
'Solution steps:\n1. Lorem ipsum dolor sit amet, consectetur **adipiscing** elit. Proin id nulla placerat, tristique ex at, euismod dui.\n2. Copy this into somewhere\n3. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Proin id nulla placerat, tristique ex at, euismod dui.\n4. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Proin id nulla placerat, tristique ex at, euismod dui. \n Testing more code \n - Unordered item 1 \n - Unordered item 2',
|
||||
read: false,
|
||||
},
|
||||
]),
|
||||
};
|
||||
|
||||
export const SummaryTitleStreaming = Template.bind({});
|
||||
SummaryTitleStreaming.args = {
|
||||
user: {
|
||||
firstName: 'Max',
|
||||
lastName: 'Test',
|
||||
},
|
||||
messages: getMessages([
|
||||
{
|
||||
id: '123',
|
||||
role: 'assistant',
|
||||
type: 'block',
|
||||
title: 'Credential doesn’t have',
|
||||
content: '',
|
||||
read: false,
|
||||
},
|
||||
]),
|
||||
streaming: true,
|
||||
};
|
||||
|
||||
export const SummaryContentStreaming = Template.bind({});
|
||||
SummaryContentStreaming.args = {
|
||||
user: {
|
||||
firstName: 'Max',
|
||||
lastName: 'Test',
|
||||
},
|
||||
messages: getMessages([
|
||||
{
|
||||
id: '123',
|
||||
role: 'assistant',
|
||||
type: 'block',
|
||||
title: 'Credential doesn’t have correct permissions to send a message',
|
||||
content: 'Solution steps:\n1. Lorem ipsum dolor sit amet, consectetur',
|
||||
read: false,
|
||||
},
|
||||
]),
|
||||
streaming: true,
|
||||
};
|
||||
|
||||
export const ErrorChat = Template.bind({});
|
||||
ErrorChat.args = {
|
||||
user: {
|
||||
firstName: 'Max',
|
||||
lastName: 'Test',
|
||||
},
|
||||
messages: getMessages([
|
||||
{
|
||||
id: '123',
|
||||
role: 'assistant',
|
||||
type: 'error',
|
||||
content: 'There was an error reaching the service',
|
||||
read: false,
|
||||
},
|
||||
]),
|
||||
};
|
||||
|
||||
export const EmptyStreamingChat = Template.bind({});
|
||||
EmptyStreamingChat.args = {
|
||||
user: {
|
||||
firstName: 'Max',
|
||||
lastName: 'Test',
|
||||
},
|
||||
messages: getMessages([
|
||||
{
|
||||
id: '123',
|
||||
type: 'text',
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
read: false,
|
||||
},
|
||||
]),
|
||||
streaming: true,
|
||||
};
|
||||
|
||||
export const StreamingChat = Template.bind({});
|
||||
StreamingChat.args = {
|
||||
user: {
|
||||
firstName: 'Max',
|
||||
lastName: 'Test',
|
||||
},
|
||||
messages: getMessages([
|
||||
{
|
||||
id: '123',
|
||||
type: 'text',
|
||||
role: 'assistant',
|
||||
content: 'I am thinking through this problem',
|
||||
read: false,
|
||||
},
|
||||
]),
|
||||
streaming: true,
|
||||
};
|
||||
|
||||
export const EndOfSessionChat = Template.bind({});
|
||||
EndOfSessionChat.args = {
|
||||
user: {
|
||||
firstName: 'Max',
|
||||
lastName: 'Test',
|
||||
},
|
||||
messages: getMessages([
|
||||
{
|
||||
id: '123',
|
||||
type: 'text',
|
||||
role: 'assistant',
|
||||
content: "Great, glad I could help! I'm here whenever you need more help.",
|
||||
read: false,
|
||||
},
|
||||
{
|
||||
id: '123',
|
||||
role: 'assistant',
|
||||
type: 'event',
|
||||
eventName: 'end-session',
|
||||
read: false,
|
||||
},
|
||||
]),
|
||||
};
|
@ -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>
|
@ -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',
|
||||
};
|
@ -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>
|
@ -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',
|
||||
};
|
@ -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>
|
@ -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({});
|
22
packages/design-system/src/components/BetaTag/BetaTag.vue
Normal file
22
packages/design-system/src/components/BetaTag/BetaTag.vue
Normal 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>
|
@ -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({});
|
@ -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>
|
@ -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,
|
||||
};
|
207
packages/design-system/src/components/CodeDiff/CodeDiff.vue
Normal file
207
packages/design-system/src/components/CodeDiff/CodeDiff.vue
Normal 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'"> + </span>
|
||||
<span v-else-if="diff.type === 'del'"> - </span>
|
||||
<span v-else> </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>
|
@ -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 };
|
@ -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>
|
@ -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;
|
||||
|
@ -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]}` +
|
||||
|
@ -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>
|
||||
|
||||
|
@ -465,6 +465,8 @@
|
||||
var(--prim-color-alt-k-s),
|
||||
var(--prim-color-alt-k-l)
|
||||
);
|
||||
|
||||
--prim-color-white: hsl(0, 0%, 100%);
|
||||
}
|
||||
|
||||
:root {
|
||||
|
@ -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);
|
||||
|
@ -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);
|
||||
|
@ -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;
|
||||
|
@ -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;
|
||||
|
69
packages/design-system/src/types/assistant.ts
Normal file
69
packages/design-system/src/types/assistant.ts
Normal 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;
|
||||
};
|
||||
}
|
@ -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;
|
||||
|
@ -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%;
|
||||
|
@ -121,4 +121,7 @@ export const defaultSettings: IN8nUISettings = {
|
||||
security: {
|
||||
blockFileAccessToN8nFiles: false,
|
||||
},
|
||||
aiAssistant: {
|
||||
enabled: false,
|
||||
},
|
||||
};
|
||||
|
25
packages/editor-ui/src/api/assistant.ts
Normal file
25
packages/editor-ui/src/api/assistant.ts
Normal 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,
|
||||
);
|
||||
}
|
@ -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>
|
@ -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>
|
@ -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>
|
@ -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;
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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) {
|
||||
|
@ -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) {
|
||||
|
@ -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);
|
||||
|
@ -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"
|
||||
|
@ -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;
|
||||
|
@ -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) {
|
||||
|
@ -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 = {
|
||||
|
@ -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",
|
||||
|
@ -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',
|
||||
],
|
||||
};
|
||||
|
@ -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);
|
||||
},
|
||||
|
554
packages/editor-ui/src/stores/assistant.store.ts
Normal file
554
packages/editor-ui/src/stores/assistant.store.ts
Normal 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,
|
||||
};
|
||||
});
|
@ -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,
|
||||
|
@ -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]: {
|
||||
|
123
packages/editor-ui/src/types/assistant.types.ts
Normal file
123
packages/editor-ui/src/types/assistant.types.ts
Normal 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;
|
||||
}
|
||||
}
|
@ -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));
|
||||
}
|
||||
};
|
||||
|
@ -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,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
@ -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) {
|
||||
|
@ -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';
|
||||
};
|
||||
|
@ -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:
|
||||
|
Loading…
Reference in New Issue
Block a user