feat: AI workflow builder front-end (no-changelog) (#14820)

Co-authored-by: Giulio Andreini <g.andreini@gmail.com>
This commit is contained in:
oleg
2025-04-28 15:38:32 +02:00
committed by GitHub
parent dbffcdc2ff
commit 97055d5714
56 changed files with 3857 additions and 1067 deletions

View File

@@ -1 +1,26 @@
@import url('https://fonts.googleapis.com/css?family=Open+Sans&display=swap');
/* Storybook-specific font paths */
@font-face {
font-family: InterVariable;
font-style: normal;
font-weight: 100 900;
font-display: swap;
src: url('../src/css/fonts/InterVariable.woff2') format('woff2');
}
@font-face {
font-family: InterVariable;
font-style: italic;
font-weight: 100 900;
font-display: swap;
src: url('../src/css/fonts/InterVariable-Italic.woff2') format('woff2');
}
@font-face {
font-family: CommitMono;
font-style: italic;
font-weight: 100 900;
font-display: swap;
src: url('../src/css/fonts/CommitMonoVariable.woff2') format('woff2');
}
@import '../src/css/_tokens.scss';

View File

@@ -10,7 +10,7 @@ const { t } = useI18n();
const hovering = ref(false);
const props = defineProps<{ unreadCount?: number }>();
const props = defineProps<{ unreadCount?: number; type?: 'assistant' | 'builder' }>();
const emit = defineEmits<{
click: [e: MouseEvent];
@@ -40,7 +40,13 @@ function onMouseLeave() {
<AssistantIcon v-else size="large" :theme="hovering ? 'blank' : 'default'" />
<div v-show="hovering" :class="$style.text">
<div>
<AssistantText :text="t('askAssistantButton.askAssistant')" />
<AssistantText
:text="
type === 'builder'
? t('assistantChat.builder.name')
: t('askAssistantButton.askAssistant')
"
/>
</div>
<div>
<BetaTag />

View File

@@ -25,6 +25,24 @@ const Template: StoryFn = (args, { argTypes }) => ({
methods,
});
const TemplateWithInputPlaceholder: StoryFn = (args, { argTypes }) => ({
setup: () => ({ args }),
props: Object.keys(argTypes),
components: {
AskAssistantChat,
},
template: `
<div style="width:275px; height:500px">
<ask-assistant-chat v-bind="args" >
<template #inputPlaceholder>
<button>Click me</button>
</template>
</ask-assistant-chat>
</div>
`,
methods,
});
export const DefaultPlaceholderChat = Template.bind({});
DefaultPlaceholderChat.args = {
user: {
@@ -33,6 +51,14 @@ DefaultPlaceholderChat.args = {
},
};
export const InputPlaceholderChat = TemplateWithInputPlaceholder.bind({});
DefaultPlaceholderChat.args = {
user: {
firstName: 'Max',
lastName: 'Test',
},
};
export const Chat = Template.bind({});
Chat.args = {
user: {
@@ -78,7 +104,7 @@ Chat.args = {
id: '2',
type: 'block',
role: 'assistant',
title: 'Credential doesnt have correct permissions to send a message',
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,
@@ -117,7 +143,7 @@ JustSummary.args = {
id: '123',
role: 'assistant',
type: 'block',
title: 'Credential doesnt have correct permissions to send a message',
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,
@@ -136,7 +162,7 @@ SummaryTitleStreaming.args = {
id: '123',
role: 'assistant',
type: 'block',
title: 'Credential doesnt have',
title: "Credential doesn't have",
content: '',
read: false,
},
@@ -155,7 +181,7 @@ SummaryContentStreaming.args = {
id: '123',
role: 'assistant',
type: 'block',
title: 'Credential doesnt have correct permissions to send a message',
title: "Credential doesn't have correct permissions to send a message",
content: 'Solution steps:\n1. Lorem ipsum dolor sit amet, consectetur',
read: false,
},
@@ -372,3 +398,94 @@ RichTextMessage.args = {
},
]),
};
export const WorkflowStepsChat = Template.bind({});
WorkflowStepsChat.args = {
user: {
firstName: 'Max',
lastName: 'Test',
},
messages: getMessages([
{
id: '123',
type: 'workflow-step',
role: 'assistant',
steps: [
'Create a new HTTP Trigger node',
'Add a Transform node to process the data',
'Connect to your database using PostgreSQL node',
'Send confirmation email with SendGrid node',
],
read: false,
},
]),
};
export const WorkflowNodesChat = Template.bind({});
WorkflowNodesChat.args = {
user: {
firstName: 'Max',
lastName: 'Test',
},
messages: getMessages([
{
id: '124',
type: 'workflow-node',
role: 'assistant',
nodes: ['HTTP Trigger', 'Transform', 'PostgreSQL', 'SendGrid'],
read: false,
},
]),
};
export const ComposedNodesChat = Template.bind({});
ComposedNodesChat.args = {
user: {
firstName: 'Max',
lastName: 'Test',
},
messages: getMessages([
{
id: '125',
type: 'workflow-composed',
role: 'assistant',
nodes: [
{
name: 'HTTP Trigger',
type: 'n8n-nodes-base.httpTrigger',
parameters: {
path: '/webhook',
authentication: 'none',
},
position: [100, 100],
},
{
name: 'Transform',
type: 'n8n-nodes-base.set',
parameters: {
values: { field: 'value' },
},
position: [300, 100],
},
],
read: false,
},
]),
};
export const RateWorkflowMessage = Template.bind({});
RateWorkflowMessage.args = {
user: {
firstName: 'Max',
lastName: 'Test',
},
messages: getMessages([
{
id: '126',
type: 'rate-workflow',
role: 'assistant',
content: 'Is this workflow helpful?',
read: false,
},
]),
};

View File

@@ -1,39 +1,26 @@
<script setup lang="ts">
import Markdown from 'markdown-it';
import markdownLink from 'markdown-it-link-attributes';
import { computed, ref } from 'vue';
import BlockMessage from './messages/BlockMessage.vue';
import CodeDiffMessage from './messages/CodeDiffMessage.vue';
import ErrorMessage from './messages/ErrorMessage.vue';
import EventMessage from './messages/EventMessage.vue';
import TextMessage from './messages/TextMessage.vue';
import ComposedNodesMessage from './messages/workflow/ComposedNodesMessage.vue';
import RateWorkflowMessage from './messages/workflow/RateWorkflowMessage.vue';
import WorkflowGeneratedMessage from './messages/workflow/WorkflowGeneratedMessage.vue';
import WorkflowNodesMessage from './messages/workflow/WorkflowNodesMessage.vue';
import WorkflowStepsMessage from './messages/workflow/WorkflowStepsMessage.vue';
import { useI18n } from '../../composables/useI18n';
import type { ChatUI } from '../../types/assistant';
import AssistantAvatar from '../AskAssistantAvatar/AssistantAvatar.vue';
import AssistantIcon from '../AskAssistantIcon/AssistantIcon.vue';
import AssistantLoadingMessage from '../AskAssistantLoadingMessage/AssistantLoadingMessage.vue';
import AssistantText from '../AskAssistantText/AssistantText.vue';
import BetaTag from '../BetaTag/BetaTag.vue';
import BlinkingCursor from '../BlinkingCursor/BlinkingCursor.vue';
import CodeDiff from '../CodeDiff/CodeDiff.vue';
import InlineAskAssistantButton from '../InlineAskAssistantButton/InlineAskAssistantButton.vue';
const { t } = useI18n();
const md = new Markdown({
breaks: true,
});
md.use(markdownLink, {
attrs: {
target: '_blank',
rel: 'noopener',
},
});
// Wrap tables in div
md.renderer.rules.table_open = function () {
return '<div class="table-wrapper"><table>';
};
md.renderer.rules.table_close = function () {
return '</table></div>';
};
const MAX_CHAT_INPUT_HEIGHT = 100;
interface Props {
@@ -45,6 +32,8 @@ interface Props {
streaming?: boolean;
loadingMessage?: string;
sessionId?: string;
title?: string;
placeholder?: string;
}
const emit = defineEmits<{
@@ -52,11 +41,23 @@ const emit = defineEmits<{
message: [string, string?, boolean?];
codeReplace: [number];
codeUndo: [number];
thumbsUp: [];
thumbsDown: [];
submitFeedback: [string];
}>();
const onClose = () => emit('close');
const props = defineProps<Props>();
const props = withDefaults(defineProps<Props>(), {
title: () => useI18n().t('assistantChat.aiAssistantLabel'),
user: () => ({
firstName: '',
lastName: '',
}),
messages: () => [],
loadingMessage: undefined,
sessionId: undefined,
});
const textInputValue = ref<string>('');
@@ -74,10 +75,6 @@ const showPlaceholder = computed(() => {
return !props.messages?.length && !props.loadingMessage && !props.sessionId;
});
const isClipboardSupported = computed(() => {
return navigator.clipboard?.writeText;
});
function isEndOfSessionEvent(event?: ChatUI.AssistantMessage) {
return event?.type === 'event' && event?.eventName === 'end-session';
}
@@ -95,15 +92,6 @@ function onSendMessage() {
}
}
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';
@@ -111,13 +99,16 @@ function growInput() {
chatInput.value.style.height = `${Math.min(scrollHeight, MAX_CHAT_INPUT_HEIGHT)}px`;
}
async function onCopyButtonClick(content: string, e: MouseEvent) {
const button = e.target as HTMLButtonElement;
await navigator.clipboard.writeText(content);
button.innerText = t('assistantChat.copied');
setTimeout(() => {
button.innerText = t('assistantChat.copy');
}, 2000);
function onThumbsUp() {
emit('thumbsUp');
}
function onThumbsDown() {
emit('thumbsDown');
}
function onSubmitFeedback(feedback: string) {
emit('submitFeedback', feedback);
}
</script>
@@ -127,9 +118,10 @@ async function onCopyButtonClick(content: string, e: MouseEvent) {
<div :class="$style.chatTitle">
<div :class="$style.headerText">
<AssistantIcon size="large" />
<AssistantText size="large" :text="t('assistantChat.aiAssistantLabel')" />
<AssistantText size="large" :text="title" />
</div>
<BetaTag />
<slot name="header" />
</div>
<div :class="$style.back" data-test-id="close-chat-button" @click="onClose">
<n8n-icon icon="arrow-right" color="text-base" />
@@ -138,140 +130,91 @@ async function onCopyButtonClick(content: string, e: MouseEvent) {
<div :class="$style.body">
<div v-if="messages?.length || loadingMessage" :class="$style.messages">
<div v-if="messages?.length">
<div
<data
v-for="(message, i) in messages"
:key="i"
:class="$style.message"
:data-test-id="
message.role === 'assistant' ? 'chat-message-assistant' : 'chat-message-user'
"
>
<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">
<span
v-n8n-html="renderMarkdown(message.content)"
:class="$style['rendered-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">
<span
v-if="message.role === 'user'"
v-n8n-html="renderMarkdown(message.content)"
:class="$style['rendered-content']"
></span>
<div
v-else
v-n8n-html="renderMarkdown(message.content)"
:class="[$style.assistantText, $style['rendered-content']]"
></div>
<div
v-if="message?.codeSnippet"
:class="$style['code-snippet']"
data-test-id="assistant-code-snippet"
>
<header v-if="isClipboardSupported">
<n8n-button
type="tertiary"
text="true"
size="mini"
data-test-id="assistant-copy-snippet-button"
@click="onCopyButtonClick(message.codeSnippet, $event)"
>
{{ t('assistantChat.copy') }}
</n8n-button>
</header>
<div
v-n8n-html="renderMarkdown(message.codeSnippet).trim()"
data-test-id="assistant-code-snippet-content"
:class="[$style['snippet-content'], $style['rendered-content']]"
></div>
</div>
<BlinkingCursor
v-if="streaming && i === messages?.length - 1 && message.role === 'assistant'"
/>
</div>
<div
<TextMessage
v-if="message.type === 'text'"
:message="message"
:is-first-of-role="i === 0 || message.role !== messages[i - 1].role"
:user="user"
:streaming="streaming"
:is-last-message="i === messages.length - 1"
/>
<BlockMessage
v-else-if="message.type === 'block'"
:message="message"
:is-first-of-role="i === 0 || message.role !== messages[i - 1].role"
:user="user"
:streaming="streaming"
:is-last-message="i === messages.length - 1"
/>
<ErrorMessage
v-else-if="message.type === 'error'"
:class="$style.error"
data-test-id="chat-message-system"
>
<span>⚠️ {{ message.content }}</span>
<n8n-button
v-if="message.retry"
type="secondary"
size="mini"
:class="$style.retryButton"
data-test-id="error-retry-button"
@click="() => message.retry?.()"
>
{{ t('generic.retry') }}
</n8n-button>
</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"
data-test-id="chat-message-system"
>
<span>
{{ t('assistantChat.sessionEndMessage.1') }}
</span>
<InlineAskAssistantButton size="small" :static="true" />
<span>
{{ t('assistantChat.sessionEndMessage.2') }}
</span>
</div>
:message="message"
:is-first-of-role="i === 0 || message.role !== messages[i - 1].role"
:user="user"
/>
<EventMessage
v-else-if="message.type === 'event'"
:message="message"
:is-first-of-role="i === 0 || message.role !== messages[i - 1].role"
:user="user"
/>
<CodeDiffMessage
v-else-if="message.type === 'code-diff'"
:message="message"
:is-first-of-role="i === 0 || message.role !== messages[i - 1].role"
:user="user"
:streaming="streaming"
:is-last-message="i === messages.length - 1"
@code-replace="() => emit('codeReplace', i)"
@code-undo="() => emit('codeUndo', i)"
/>
<WorkflowStepsMessage
v-else-if="message.type === 'workflow-step'"
:message="message"
:is-first-of-role="i === 0 || message.role !== messages[i - 1].role"
:user="user"
/>
<WorkflowNodesMessage
v-else-if="message.type === 'workflow-node'"
:message="message"
:is-first-of-role="i === 0 || message.role !== messages[i - 1].role"
:user="user"
/>
<ComposedNodesMessage
v-else-if="message.type === 'workflow-composed'"
:message="message"
:is-first-of-role="i === 0 || message.role !== messages[i - 1].role"
:user="user"
/>
<WorkflowGeneratedMessage
v-else-if="message.type === 'workflow-generated'"
:message="message"
:is-first-of-role="i === 0 || message.role !== messages[i - 1].role"
:user="user"
/>
<RateWorkflowMessage
v-else-if="message.type === 'rate-workflow'"
:message="message"
:is-first-of-role="i === 0 || message.role !== messages[i - 1].role"
:user="user"
@thumbs-up="onThumbsUp"
@thumbs-down="onThumbsDown"
@submit-feedback="onSubmitFeedback"
/>
<div
v-if="
!streaming &&
'quickReplies' in message &&
message.quickReplies?.length &&
i === messages?.length - 1
i === messages.length - 1
"
:class="$style.quickReplies"
>
@@ -289,7 +232,7 @@ async function onCopyButtonClick(content: string, e: MouseEvent) {
</n8n-button>
</div>
</div>
</div>
</data>
</div>
<div
v-if="loadingMessage"
@@ -303,48 +246,59 @@ async function onCopyButtonClick(content: string, e: MouseEvent) {
:class="$style.placeholder"
data-test-id="placeholder-message"
>
<div :class="$style.greeting">Hi {{ user?.firstName }} 👋</div>
<div :class="$style.info">
<p>
{{ t('assistantChat.placeholder.1') }}
</p>
<p>
{{ t('assistantChat.placeholder.2') }}
<InlineAskAssistantButton size="small" :static="true" />
{{ t('assistantChat.placeholder.3') }}
</p>
<p>
{{ t('assistantChat.placeholder.4') }}
</p>
<div v-if="$slots.placeholder" :class="$style.info">
<slot name="placeholder" />
</div>
<template v-else>
<div :class="$style.greeting">Hi {{ user?.firstName }} 👋</div>
<div :class="$style.info">
<p>
{{ t('assistantChat.placeholder.1') }}
</p>
<p>
{{ t('assistantChat.placeholder.2') }}
<InlineAskAssistantButton size="small" :static="true" />
{{ t('assistantChat.placeholder.3') }}
</p>
<p>
{{ t('assistantChat.placeholder.4') }}
</p>
</div>
</template>
</div>
</div>
<div
:class="{ [$style.inputWrapper]: true, [$style.disabledInput]: sessionEnded }"
data-test-id="chat-input-wrapper"
>
<textarea
ref="chatInput"
v-model="textInputValue"
class="ignore-key-press-node-creator ignore-key-press-canvas"
:disabled="sessionEnded"
:placeholder="t('assistantChat.inputPlaceholder')"
rows="1"
wrap="hard"
data-test-id="chat-input"
@keydown.enter.exact.prevent="onSendMessage"
@input.prevent="growInput"
@keydown.stop
/>
<n8n-icon-button
:class="{ [$style.sendButton]: true }"
icon="paper-plane"
type="text"
size="large"
data-test-id="send-message-button"
:disabled="sendDisabled"
@click="onSendMessage"
/>
<div v-if="$slots.inputPlaceholder" :class="$style.inputPlaceholder">
<slot name="inputPlaceholder" />
</div>
<template v-else>
<textarea
ref="chatInput"
v-model="textInputValue"
class="ignore-key-press-node-creator ignore-key-press-canvas"
:class="{ [$style.disabled]: sessionEnded || streaming }"
:disabled="sessionEnded || streaming"
:placeholder="placeholder ?? t('assistantChat.inputPlaceholder')"
rows="1"
wrap="hard"
data-test-id="chat-input"
@keydown.enter.exact.prevent="onSendMessage"
@input.prevent="growInput"
@keydown.stop
/>
<n8n-icon-button
:class="{ [$style.sendButton]: true }"
icon="paper-plane"
type="text"
size="large"
data-test-id="send-message-button"
:disabled="sendDisabled"
@click="onSendMessage"
/>
</template>
</div>
</div>
</template>
@@ -357,11 +311,6 @@ async function onCopyButtonClick(content: string, e: MouseEvent) {
grid-template-rows: auto 1fr auto;
}
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);
@@ -420,23 +369,6 @@ p {
}
}
.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);
@@ -477,78 +409,6 @@ p {
color: var(--color-text-base);
}
.textMessage {
display: flex;
flex-direction: column;
gap: var(--spacing-xs);
font-size: var(--font-size-2xs);
word-break: break-word;
}
code[class^='language-'] {
display: block;
padding: var(--spacing-4xs);
}
.code-snippet {
position: relative;
border: var(--border-base);
background-color: var(--color-foreground-xlight);
border-radius: var(--border-radius-base);
font-family: var(--font-family-monospace);
font-size: var(--font-size-3xs);
max-height: 218px; // 12 lines
overflow: auto;
margin: var(--spacing-4s) 0;
header {
display: flex;
justify-content: flex-end;
padding: var(--spacing-4xs);
border-bottom: var(--border-base);
button:active,
button:focus {
outline: none !important;
}
}
.snippet-content {
padding: var(--spacing-2xs);
}
pre {
white-space-collapse: collapse;
}
code {
background-color: transparent;
font-size: var(--font-size-3xs);
}
}
.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 {
display: flex;
background-color: var(--color-foreground-xlight);
@@ -577,91 +437,6 @@ code[class^='language-'] {
}
}
.error {
color: var(--color-danger);
display: flex;
flex-direction: column;
align-items: start;
}
.retryButton {
margin-top: var(--spacing-3xs);
}
.assistantText {
display: inline-flex;
flex-direction: column;
}
.rendered-content {
p {
margin: 0;
margin: var(--spacing-4xs) 0;
}
h1,
h2,
h3 {
font-weight: var(--font-weight-bold);
font-size: var(--font-size-xs);
margin: var(--spacing-xs) 0 var(--spacing-4xs);
}
h4,
h5,
h6 {
font-weight: var(--font-weight-bold);
font-size: var(--font-size-2xs);
}
ul,
ol {
margin: var(--spacing-4xs) 0 var(--spacing-4xs) var(--spacing-l);
ul,
ol {
margin-left: var(--spacing-xs);
margin-top: var(--spacing-4xs);
}
}
:global(.table-wrapper) {
overflow-x: auto;
}
table {
margin: var(--spacing-4xs) 0;
th {
white-space: nowrap;
min-width: 120px;
width: auto;
}
th,
td {
border: var(--border-base);
padding: var(--spacing-4xs);
}
}
}
.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;
@@ -669,4 +444,13 @@ code[class^='language-'] {
cursor: not-allowed;
}
}
textarea.disabled {
background-color: var(--color-foreground-base);
cursor: not-allowed;
}
.inputPlaceholder {
width: 100%;
}
</style>

View File

@@ -0,0 +1,65 @@
<script setup lang="ts">
import { computed } from 'vue';
import { useI18n } from '../../../composables/useI18n';
import type { ChatUI } from '../../../types/assistant';
import AssistantAvatar from '../../AskAssistantAvatar/AssistantAvatar.vue';
interface Props {
message: ChatUI.AssistantMessage;
isFirstOfRole: boolean;
user?: {
firstName: string;
lastName: string;
};
}
const props = defineProps<Props>();
const { t } = useI18n();
const isUserMessage = computed(() => props.message.role === 'user');
</script>
<template>
<div :class="$style.message">
<div
v-if="isFirstOfRole"
:class="{ [$style.roleName]: true, [$style.userSection]: !isUserMessage }"
>
<template v-if="isUserMessage">
<n8n-avatar :first-name="user?.firstName" :last-name="user?.lastName" size="xsmall" />
<span>{{ t('assistantChat.you') }}</span>
</template>
<template v-else>
<AssistantAvatar />
<span>{{ t('assistantChat.aiAssistantName') }}</span>
</template>
</div>
<slot></slot>
</div>
</template>
<style lang="scss" module>
.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);
height: var(--spacing-xl);
font-weight: var(--font-weight-bold);
font-size: var(--font-size-2xs);
> * {
margin-right: var(--spacing-3xs);
}
}
.userSection {
margin-top: var(--spacing-m);
}
</style>

View File

@@ -0,0 +1,115 @@
<script setup lang="ts">
import BaseMessage from './BaseMessage.vue';
import { useMarkdown } from './useMarkdown';
import type { ChatUI } from '../../../types/assistant';
import BlinkingCursor from '../../BlinkingCursor/BlinkingCursor.vue';
interface Props {
message: ChatUI.SummaryBlock & { id: string; read: boolean; quickReplies?: ChatUI.QuickReply[] };
isFirstOfRole: boolean;
user?: {
firstName: string;
lastName: string;
};
streaming?: boolean;
isLastMessage?: boolean;
}
defineProps<Props>();
const { renderMarkdown } = useMarkdown();
</script>
<template>
<BaseMessage :message="message" :is-first-of-role="isFirstOfRole" :user="user">
<div :class="$style.block">
<div :class="$style.blockTitle">
{{ message.title }}
<BlinkingCursor v-if="streaming && isLastMessage && !message.content" />
</div>
<div :class="$style.blockBody">
<span
v-n8n-html="renderMarkdown(message.content)"
:class="$style['rendered-content']"
></span>
<BlinkingCursor v-if="streaming && isLastMessage && message.title && message.content" />
</div>
</div>
</BaseMessage>
</template>
<style lang="scss" module>
.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);
}
.rendered-content {
p {
margin: 0;
margin: var(--spacing-4xs) 0;
}
h1,
h2,
h3 {
font-weight: var(--font-weight-bold);
font-size: var(--font-size-xs);
margin: var(--spacing-xs) 0 var(--spacing-4xs);
}
h4,
h5,
h6 {
font-weight: var(--font-weight-bold);
font-size: var(--font-size-2xs);
}
ul,
ol {
margin: var(--spacing-4xs) 0 var(--spacing-4xs) var(--spacing-l);
ul,
ol {
margin-left: var(--spacing-xs);
margin-top: var(--spacing-4xs);
}
}
:global(.table-wrapper) {
overflow-x: auto;
}
table {
margin: var(--spacing-4xs) 0;
th {
white-space: nowrap;
min-width: 120px;
width: auto;
}
th,
td {
border: var(--border-base);
padding: var(--spacing-4xs);
}
}
}
</style>

View File

@@ -0,0 +1,50 @@
<script setup lang="ts">
import BaseMessage from './BaseMessage.vue';
import type { ChatUI } from '../../../types/assistant';
import CodeDiff from '../../CodeDiff/CodeDiff.vue';
interface Props {
message: {
role: 'assistant';
type: 'code-diff';
description?: string;
codeDiff?: string;
replacing?: boolean;
replaced?: boolean;
error?: boolean;
suggestionId: string;
id: string;
read: boolean;
quickReplies?: ChatUI.QuickReply[];
};
isFirstOfRole: boolean;
user?: {
firstName: string;
lastName: string;
};
streaming?: boolean;
isLastMessage?: boolean;
}
defineProps<Props>();
const emit = defineEmits<{
codeReplace: [];
codeUndo: [];
}>();
</script>
<template>
<BaseMessage :message="message" :is-first-of-role="isFirstOfRole" :user="user">
<CodeDiff
:title="message.description"
:content="message.codeDiff"
:replacing="message.replacing"
:replaced="message.replaced"
:error="message.error"
:streaming="streaming && isLastMessage"
@replace="emit('codeReplace')"
@undo="emit('codeUndo')"
/>
</BaseMessage>
</template>

View File

@@ -0,0 +1,66 @@
<script setup lang="ts">
import BaseMessage from './BaseMessage.vue';
import { useI18n } from '../../../composables/useI18n';
import type { ChatUI } from '../../../types/assistant';
interface Props {
message: ChatUI.ErrorMessage & { id: string; read: boolean };
isFirstOfRole: boolean;
user?: {
firstName: string;
lastName: string;
};
}
defineProps<Props>();
const { t } = useI18n();
</script>
<template>
<BaseMessage :message="message" :is-first-of-role="isFirstOfRole" :user="user">
<div :class="$style.error" data-test-id="chat-message-system">
<p :class="$style.errorText">
<n8n-icon icon="exclamation-triangle" size="small" :class="$style.errorIcon" />
{{ message.content }}
</p>
<n8n-button
v-if="message.retry"
type="secondary"
size="mini"
:class="$style.retryButton"
data-test-id="error-retry-button"
@click="() => message.retry?.()"
>
{{ t('generic.retry') }}
</n8n-button>
</div>
</BaseMessage>
</template>
<style lang="scss" module>
.error {
display: flex;
align-items: center;
gap: var(--spacing-2xs);
padding: var(--spacing-2xs) var(--spacing-xs);
border: 1px solid var(--color-foreground-base);
border-radius: var(--border-radius-base);
background-color: var(--color-background-xlight);
}
.errorIcon {
margin-right: var(--spacing-5xs);
color: var(--color-danger);
}
.errorText {
color: var(--color-danger);
font-weight: var(--font-weight-regular);
line-height: var(--font-line-height-tight);
word-break: break-word;
}
.retryButton {
margin-top: var(--spacing-3xs);
}
</style>

View File

@@ -0,0 +1,72 @@
<script setup lang="ts">
import BaseMessage from './BaseMessage.vue';
import { useI18n } from '../../../composables/useI18n';
import InlineAskAssistantButton from '../../InlineAskAssistantButton/InlineAskAssistantButton.vue';
type EventName = 'end-session' | 'session-timeout' | 'session-error';
interface Props {
message: {
role: 'assistant';
type: 'event';
eventName: EventName;
id: string;
read: boolean;
};
isFirstOfRole: boolean;
user?: {
firstName: string;
lastName: string;
};
}
defineProps<Props>();
const { t } = useI18n();
const eventMessages: Record<EventName, { part1: string; part2: string }> = {
'end-session': {
part1: 'assistantChat.sessionEndMessage.1',
part2: 'assistantChat.sessionEndMessage.2',
},
'session-timeout': {
part1: 'assistantChat.sessionTimeoutMessage.1',
part2: 'assistantChat.sessionTimeoutMessage.2',
},
'session-error': {
part1: 'assistantChat.sessionErrorMessage.1',
part2: 'assistantChat.sessionErrorMessage.2',
},
} as const;
</script>
<template>
<BaseMessage :message="message" :is-first-of-role="isFirstOfRole" :user="user">
<div :class="$style.eventText" data-test-id="chat-message-system">
<span>
{{ t(eventMessages[message.eventName]?.part1 || 'assistantChat.unknownEvent') }}
</span>
<InlineAskAssistantButton size="small" :static="true" />
<span>
{{ t(eventMessages[message.eventName]?.part2 || '') }}
</span>
</div>
</BaseMessage>
</template>
<style lang="scss" module>
.eventText {
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;
}
}
</style>

View File

@@ -0,0 +1,182 @@
<script setup lang="ts">
import { computed } from 'vue';
import BaseMessage from './BaseMessage.vue';
import { useMarkdown } from './useMarkdown';
import { useI18n } from '../../../composables/useI18n';
import type { ChatUI } from '../../../types/assistant';
import BlinkingCursor from '../../BlinkingCursor/BlinkingCursor.vue';
interface Props {
message: ChatUI.TextMessage & { id: string; read: boolean; quickReplies?: ChatUI.QuickReply[] };
isFirstOfRole: boolean;
user?: {
firstName: string;
lastName: string;
};
streaming?: boolean;
isLastMessage?: boolean;
}
defineProps<Props>();
const { renderMarkdown } = useMarkdown();
const { t } = useI18n();
const isClipboardSupported = computed(() => {
return navigator.clipboard?.writeText;
});
async function onCopyButtonClick(content: string, e: MouseEvent) {
const button = e.target as HTMLButtonElement;
await navigator.clipboard.writeText(content);
button.innerText = t('assistantChat.copied');
setTimeout(() => {
button.innerText = t('assistantChat.copy');
}, 2000);
}
</script>
<template>
<BaseMessage :message="message" :is-first-of-role="isFirstOfRole" :user="user">
<div :class="$style.textMessage">
<span
v-if="message.role === 'user'"
v-n8n-html="renderMarkdown(message.content)"
:class="$style['rendered-content']"
></span>
<div
v-else
v-n8n-html="renderMarkdown(message.content)"
:class="[$style.assistantText, $style['rendered-content']]"
></div>
<div
v-if="message?.codeSnippet"
:class="$style['code-snippet']"
data-test-id="assistant-code-snippet"
>
<header v-if="isClipboardSupported">
<n8n-button
type="tertiary"
text="true"
size="mini"
data-test-id="assistant-copy-snippet-button"
@click="onCopyButtonClick(message.codeSnippet, $event)"
>
{{ t('assistantChat.copy') }}
</n8n-button>
</header>
<div
v-n8n-html="renderMarkdown(message.codeSnippet).trim()"
data-test-id="assistant-code-snippet-content"
:class="[$style['snippet-content'], $style['rendered-content']]"
></div>
</div>
<BlinkingCursor v-if="streaming && isLastMessage && message.role === 'assistant'" />
</div>
</BaseMessage>
</template>
<style lang="scss" module>
.textMessage {
display: flex;
flex-direction: column;
gap: var(--spacing-xs);
font-size: var(--font-size-2xs);
word-break: break-word;
}
.code-snippet {
position: relative;
border: var(--border-base);
background-color: var(--color-foreground-xlight);
border-radius: var(--border-radius-base);
font-family: var(--font-family-monospace);
font-size: var(--font-size-3xs);
max-height: 218px; // 12 lines
overflow: auto;
margin: var(--spacing-4s) 0;
header {
display: flex;
justify-content: flex-end;
padding: var(--spacing-4xs);
border-bottom: var(--border-base);
button:active,
button:focus {
outline: none !important;
}
}
.snippet-content {
padding: var(--spacing-2xs);
}
pre {
white-space-collapse: collapse;
}
code {
background-color: transparent;
font-size: var(--font-size-3xs);
}
}
.assistantText {
display: inline-flex;
flex-direction: column;
}
.rendered-content {
p {
margin: 0;
margin: var(--spacing-4xs) 0;
}
h1,
h2,
h3 {
font-weight: var(--font-weight-bold);
font-size: var(--font-size-xs);
margin: var(--spacing-xs) 0 var(--spacing-4xs);
}
h4,
h5,
h6 {
font-weight: var(--font-weight-bold);
font-size: var(--font-size-2xs);
}
ul,
ol {
margin: var(--spacing-4xs) 0 var(--spacing-4xs) var(--spacing-l);
ul,
ol {
margin-left: var(--spacing-xs);
margin-top: var(--spacing-4xs);
}
}
:global(.table-wrapper) {
overflow-x: auto;
}
table {
margin: var(--spacing-4xs) 0;
th {
white-space: nowrap;
min-width: 120px;
width: auto;
}
th,
td {
border: var(--border-base);
padding: var(--spacing-4xs);
}
}
}
</style>

View File

@@ -0,0 +1,32 @@
import Markdown from 'markdown-it';
import markdownLink from 'markdown-it-link-attributes';
import { useI18n } from '../../../composables/useI18n';
export function useMarkdown() {
const { t } = useI18n();
const md = new Markdown({
breaks: true,
});
md.use(markdownLink, {
attrs: {
target: '_blank',
rel: 'noopener',
},
});
function renderMarkdown(content: string) {
try {
return md.render(content);
} catch (e) {
console.error(`Error parsing markdown content ${content}`);
return `<p>${t('assistantChat.errorParsingMarkdown')}</p>`;
}
}
return {
renderMarkdown,
};
}

View File

@@ -0,0 +1,71 @@
<script setup lang="ts">
import AssistantLoadingMessage from '@n8n/design-system/components/AskAssistantLoadingMessage/AssistantLoadingMessage.vue';
import type { ChatUI } from '../../../../types/assistant';
import BaseMessage from '../BaseMessage.vue';
interface Props {
message: ChatUI.AssistantMessage;
isFirstOfRole: boolean;
user?: {
firstName: string;
lastName: string;
};
nextStep?: string;
}
defineProps<Props>();
</script>
<template>
<BaseMessage :message="message" :is-first-of-role="isFirstOfRole" :user="user">
<div :class="$style.workflowMessage">
<div :class="$style.message">
<slot name="icon"></slot>
<div :class="$style.content">
<div v-if="$slots.title" :class="$style.title">
<slot name="title"></slot>
</div>
<div :class="$style.details">
<slot></slot>
</div>
</div>
</div>
<AssistantLoadingMessage v-if="nextStep" :message="nextStep" :class="$style.nextStep" />
</div>
</BaseMessage>
</template>
<style lang="scss" module>
.workflowMessage {
display: flex;
flex-direction: column;
}
.message {
display: flex;
gap: var(--spacing-s);
padding: 0 var(--spacing-2xs) var(--spacing-2xs) 0;
background-color: var(--color-background-light);
border-radius: var(--border-radius-base);
}
.nextStep {
flex: 1;
width: 100%;
}
.content {
flex: 1;
}
.title {
font-weight: var(--font-weight-medium);
margin-bottom: var(--spacing-3xs);
}
.details {
color: var(--color-text-base);
font-size: var(--font-size-2xs);
}
</style>

View File

@@ -0,0 +1,54 @@
<script setup lang="ts">
import { useI18n } from '@n8n/design-system/composables/useI18n';
import BaseWorkflowMessage from './BaseWorkflowMessage.vue';
import type { ChatUI } from '../../../../types/assistant';
interface Props {
message: ChatUI.WorkflowComposedMessage & { id: string; read: boolean };
isFirstOfRole: boolean;
user?: {
firstName: string;
lastName: string;
};
}
const { t } = useI18n();
defineProps<Props>();
</script>
<template>
<BaseWorkflowMessage
:message="message"
:is-first-of-role="isFirstOfRole"
:user="user"
:next-step="t('assistantChat.builder.generatingFinalWorkflow')"
>
<template #title>{{ t('assistantChat.builder.configuredNodes') }}</template>
<ol :class="$style.nodesList">
<li v-for="node in message.nodes" :key="node.name" :class="$style.node">
<div :class="$style.nodeName">{{ node.name }}</div>
</li>
</ol>
</BaseWorkflowMessage>
</template>
<style lang="scss" module>
.nodesList {
display: flex;
flex-direction: column;
gap: var(--spacing-3xs);
list-style-position: outside;
margin: 0;
padding: 0 0 0 var(--spacing-s);
li {
color: var(--color-text-base);
line-height: var(--font-line-height-loose);
}
}
.nodeType {
font-size: var(--font-size-3xs);
color: var(--color-text-light);
}
</style>

View File

@@ -0,0 +1,122 @@
<script setup lang="ts">
import { ref } from 'vue';
import { useI18n } from '@n8n/design-system/composables/useI18n';
import BaseWorkflowMessage from './BaseWorkflowMessage.vue';
import type { ChatUI } from '../../../../types/assistant';
interface Props {
message: ChatUI.RateWorkflowMessage & { id: string; read: boolean };
isFirstOfRole: boolean;
user?: {
firstName: string;
lastName: string;
};
}
const { t } = useI18n();
const emit = defineEmits<{
thumbsUp: [];
thumbsDown: [];
submitFeedback: [string];
}>();
defineProps<Props>();
const feedback = ref('');
const showFeedback = ref(false);
const showSuccess = ref(false);
function onRateButton(rating: 'thumbsUp' | 'thumbsDown') {
showFeedback.value = true;
if (rating === 'thumbsUp') {
emit('thumbsUp');
} else {
emit('thumbsDown');
}
}
function onSubmitFeedback() {
emit('submitFeedback', feedback.value);
showFeedback.value = false;
showSuccess.value = true;
}
</script>
<template>
<BaseWorkflowMessage :message="message" :is-first-of-role="isFirstOfRole" :user="user">
<div :class="$style.content">
<p v-if="!showSuccess">{{ message.content }}</p>
<div v-if="!showFeedback && !showSuccess" :class="$style.buttons">
<n8n-button
type="secondary"
size="small"
:label="t('assistantChat.builder.thumbsUp')"
data-test-id="message-thumbs-up-button"
icon="thumbs-up"
@click="onRateButton('thumbsUp')"
/>
<n8n-button
type="secondary"
size="small"
data-test-id="message-thumbs-down-button"
:label="t('assistantChat.builder.thumbsDown')"
icon="thumbs-down"
@click="onRateButton('thumbsDown')"
/>
</div>
<div v-if="showFeedback" :class="$style.feedbackTextArea">
<n8n-input
v-model="feedback"
:class="$style.feedbackInput"
type="textarea"
:placeholder="t('assistantChat.builder.feedbackPlaceholder')"
data-test-id="message-feedback-input"
:read-only="false"
resize="none"
:rows="5"
/>
<div :class="$style.feedbackTextArea__footer">
<n8n-button
native-type="submit"
type="secondary"
size="small"
data-test-id="message-submit-feedback-button"
@click="onSubmitFeedback"
>
{{ t('assistantChat.builder.submit') }}
</n8n-button>
</div>
</div>
<p v-if="showSuccess" :class="$style.success">{{ t('assistantChat.builder.success') }}</p>
</div>
</BaseWorkflowMessage>
</template>
<style lang="scss" module>
.content {
display: flex;
flex-direction: column;
gap: var(--spacing-2xs);
}
.buttons {
display: flex;
gap: var(--spacing-2xs);
}
.feedbackTextArea {
display: flex;
flex-direction: column;
gap: var(--spacing-2xs);
:global(.el-textarea__inner) {
resize: none;
font-family: var(--font-family);
font-size: var(--font-size-2xs);
}
}
.feedbackTextArea__footer {
display: flex;
justify-content: flex-end;
}
</style>

View File

@@ -0,0 +1,31 @@
<script setup lang="ts">
import { useI18n } from '@n8n/design-system/composables/useI18n';
import BaseWorkflowMessage from './BaseWorkflowMessage.vue';
import type { ChatUI } from '../../../../types/assistant';
interface Props {
message: ChatUI.WorkflowGeneratedMessage & { id: string; read: boolean };
isFirstOfRole: boolean;
user?: {
firstName: string;
lastName: string;
};
}
defineProps<Props>();
const { t } = useI18n();
</script>
<template>
<BaseWorkflowMessage :message="message" :is-first-of-role="isFirstOfRole" :user="user">
<p>{{ t('assistantChat.builder.workflowGenerated1') }}</p>
<p>{{ t('assistantChat.builder.workflowGenerated2') }}</p>
</BaseWorkflowMessage>
</template>
<style lang="scss" module>
.code {
white-space: pre-wrap;
}
</style>

View File

@@ -0,0 +1,48 @@
<script setup lang="ts">
import { useI18n } from '@n8n/design-system/composables/useI18n';
import BaseWorkflowMessage from './BaseWorkflowMessage.vue';
import type { ChatUI } from '../../../../types/assistant';
interface Props {
message: ChatUI.GeneratedNodesMessage & { id: string; read: boolean };
isFirstOfRole: boolean;
user?: {
firstName: string;
lastName: string;
};
}
const { t } = useI18n();
defineProps<Props>();
</script>
<template>
<BaseWorkflowMessage
:message="message"
:is-first-of-role="isFirstOfRole"
:user="user"
:next-step="t('assistantChat.builder.configuringNodes')"
>
<template #title>{{ t('assistantChat.builder.selectedNodes') }}</template>
<ol :class="$style.nodesList">
<li v-for="node in message.nodes" :key="node">{{ node }}</li>
</ol>
</BaseWorkflowMessage>
</template>
<style lang="scss" module>
.nodesList {
display: flex;
flex-direction: column;
gap: var(--spacing-3xs);
list-style-position: outside;
margin: 0;
padding-left: var(--spacing-s);
li {
color: var(--color-text-base);
line-height: var(--font-line-height-loose);
}
}
</style>

View File

@@ -0,0 +1,49 @@
<script setup lang="ts">
import { useI18n } from '@n8n/design-system/composables/useI18n';
import BaseWorkflowMessage from './BaseWorkflowMessage.vue';
import type { ChatUI } from '../../../../types/assistant';
interface Props {
message: ChatUI.WorkflowStepMessage & { id: string; read: boolean };
isFirstOfRole: boolean;
user?: {
firstName: string;
lastName: string;
};
}
defineProps<Props>();
const { t } = useI18n();
</script>
<template>
<BaseWorkflowMessage
:message="message"
:is-first-of-role="isFirstOfRole"
:user="user"
:next-step="t('assistantChat.builder.selectingNodes')"
>
<template #title>{{ t('assistantChat.builder.generatedNodes') }}</template>
<ol :class="$style.stepsList">
<li v-for="step in message.steps" :key="step">{{ step }}</li>
</ol>
</BaseWorkflowMessage>
</template>
<style lang="scss" module>
.stepsList {
display: flex;
flex-direction: column;
gap: var(--spacing-3xs);
list-style-position: outside;
margin: 0;
padding: 0 0 0 var(--spacing-s);
li {
color: var(--color-text-base);
line-height: var(--font-line-height-loose);
}
}
</style>

View File

@@ -28,12 +28,13 @@ withDefaults(
<style module lang="scss">
.container {
display: flex;
align-items: center;
gap: var(--spacing-3xs);
user-select: none;
}
.avatar {
height: var(--spacing-s);
height: var(--spacing-m);
animation: pulse 1.5s infinite;
position: relative;
}
@@ -43,12 +44,15 @@ withDefaults(
position: relative;
overflow: hidden;
line-height: 1.4rem;
height: var(--spacing-xl);
align-items: center;
}
.message {
margin: 0;
padding: 0;
font-weight: var(--font-weight-bold);
font-size: var(--font-size-2xs);
color: var(--color-text-base);
text-align: left;
}

View File

@@ -31,6 +31,20 @@ export default {
'codeDiff.undo': 'Undo',
'betaTag.beta': 'beta',
'askAssistantButton.askAssistant': 'Ask Assistant',
'assistantChat.builder.name': 'AI Builder',
'assistantChat.builder.generatingFinalWorkflow': 'Generating final workflow...',
'assistantChat.builder.configuredNodes': 'Configured nodes',
'assistantChat.builder.thumbsUp': 'Helpful',
'assistantChat.builder.thumbsDown': 'Not helpful',
'assistantChat.builder.feedbackPlaceholder': 'Tell us about your experience',
'assistantChat.builder.success': 'Thank you for your feedback!',
'assistantChat.builder.submit': 'Submit feedback',
'assistantChat.builder.workflowGenerated1': 'Your workflow was created successfully!',
'assistantChat.builder.workflowGenerated2': 'Fix any missing credentials before testing it.',
'assistantChat.builder.configuringNodes': 'Configuring nodes...',
'assistantChat.builder.selectedNodes': 'Selected workflow nodes',
'assistantChat.builder.selectingNodes': 'Selecting nodes...',
'assistantChat.builder.generatedNodes': 'Generated workflow nodes',
'assistantChat.errorParsingMarkdown': 'Error parsing markdown content',
'assistantChat.aiAssistantLabel': 'AI Assistant',
'assistantChat.aiAssistantName': 'Assistant',

View File

@@ -13,7 +13,7 @@ export namespace ChatUI {
content: string;
}
interface CodeDiffMessage {
export interface CodeDiffMessage {
role: 'assistant';
type: 'code-diff';
description?: string;
@@ -24,12 +24,41 @@ export namespace ChatUI {
suggestionId: string;
}
interface EndSessionMessage {
export interface EndSessionMessage {
role: 'assistant';
type: 'event';
eventName: 'end-session';
}
export interface SessionTimeoutMessage {
role: 'assistant';
type: 'event';
eventName: 'session-timeout';
}
export interface SessionErrorMessage {
role: 'assistant';
type: 'event';
eventName: 'session-error';
}
export interface GeneratedNodesMessage {
role: 'assistant';
type: 'workflow-node';
nodes: string[];
}
export interface ComposedNodesMessage {
role: 'assistant';
type: 'workflow-composed';
nodes: Array<{
parameters: Record<string, unknown>;
type: string;
name: string;
position: [number, number];
}>;
}
export interface QuickReply {
type: string;
text: string;
@@ -51,6 +80,39 @@ export namespace ChatUI {
suggestionId: string;
}
export interface WorkflowStepMessage {
role: 'assistant';
type: 'workflow-step';
steps: string[];
}
export interface WorkflowNodeMessage {
role: 'assistant';
type: 'workflow-node';
nodes: string[];
}
export interface WorkflowComposedMessage {
role: 'assistant';
type: 'workflow-composed';
nodes: Array<{
parameters: Record<string, unknown>;
type: string;
name: string;
position: [number, number];
}>;
}
export interface WorkflowGeneratedMessage {
role: 'assistant';
type: 'workflow-generated';
codeSnippet: string;
}
export interface RateWorkflowMessage {
role: 'assistant';
type: 'rate-workflow';
content: string;
}
type MessagesWithReplies = (
| TextMessage
| CodeDiffMessage
@@ -64,7 +126,14 @@ export namespace ChatUI {
| MessagesWithReplies
| ErrorMessage
| EndSessionMessage
| SessionTimeoutMessage
| SessionErrorMessage
| AgentSuggestionMessage
| WorkflowStepMessage
| WorkflowNodeMessage
| WorkflowComposedMessage
| WorkflowGeneratedMessage
| RateWorkflowMessage
) & {
id: string;
read: boolean;

View File

@@ -5,14 +5,15 @@ import { ref, computed, watch, onMounted, onBeforeUnmount, nextTick } from 'vue'
import { useRoute } from 'vue-router';
import LoadingView from '@/views/LoadingView.vue';
import BannerStack from '@/components/banners/BannerStack.vue';
import AskAssistantChat from '@/components/AskAssistant/AskAssistantChat.vue';
import Modals from '@/components/Modals.vue';
import Telemetry from '@/components/Telemetry.vue';
import AskAssistantFloatingButton from '@/components/AskAssistant/AskAssistantFloatingButton.vue';
import AskAssistantFloatingButton from '@/components/AskAssistant/Chat/AskAssistantFloatingButton.vue';
import AssistantsHub from '@/components/AskAssistant/AssistantsHub.vue';
import { loadLanguage } from '@/plugins/i18n';
import { APP_MODALS_ELEMENT_ID, HIRING_BANNER, VIEWS } from '@/constants';
import { useRootStore } from '@/stores/root.store';
import { useAssistantStore } from '@/stores/assistant.store';
import { useBuilderStore } from '@/stores/builder.store';
import { useUIStore } from '@/stores/ui.store';
import { useUsersStore } from '@/stores/users.store';
import { useSettingsStore } from '@/stores/settings.store';
@@ -22,6 +23,7 @@ import { useStyles } from './composables/useStyles';
const route = useRoute();
const rootStore = useRootStore();
const assistantStore = useAssistantStore();
const builderStore = useBuilderStore();
const uiStore = useUIStore();
const usersStore = useUsersStore();
const settingsStore = useSettingsStore();
@@ -39,6 +41,7 @@ const hasContentFooter = ref(false);
const appGrid = ref<Element | null>(null);
const assistantSidebarWidth = computed(() => assistantStore.chatWidth);
const builderSidebarWidth = computed(() => builderStore.chatWidth);
onMounted(async () => {
setAppZIndexes();
@@ -65,9 +68,8 @@ const updateGridWidth = async () => {
uiStore.appGridDimensions = { width, height };
}
};
// As assistant sidebar width changes, recalculate the total width regularly
watch(assistantSidebarWidth, async () => {
watch([assistantSidebarWidth, builderSidebarWidth], async () => {
await updateGridWidth();
});
@@ -121,7 +123,7 @@ watch(defaultLocale, (newLocale) => {
<Telemetry />
<AskAssistantFloatingButton v-if="showAssistantButton" />
</div>
<AskAssistantChat />
<AssistantsHub />
</div>
</template>

View File

@@ -6,6 +6,23 @@ import { makeRestApiRequest, streamRequest } from '@/utils/apiUtils';
import { getObjectSizeInKB } from '@/utils/objectUtils';
import type { IDataObject } from 'n8n-workflow';
export function chatWithBuilder(
ctx: IRestApiContext,
payload: ChatRequest.RequestPayload,
onMessageUpdated: (data: ChatRequest.ResponsePayload) => void,
onDone: () => void,
onError: (e: Error) => void,
): void {
void streamRequest<ChatRequest.ResponsePayload>(
ctx,
'/ai/build',
payload,
onMessageUpdated,
onDone,
onError,
);
}
export function chatWithAssistant(
ctx: IRestApiContext,
payload: ChatRequest.RequestPayload,

View File

@@ -0,0 +1,195 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { createComponentRenderer } from '@/__tests__/render';
import { setActivePinia } from 'pinia';
import { createTestingPinia } from '@pinia/testing';
import { flushPromises } from '@vue/test-utils';
import { fireEvent } from '@testing-library/vue';
import { faker } from '@faker-js/faker';
import AskAssistantBuild from './AskAssistantBuild.vue';
import { useBuilderStore } from '@/stores/builder.store';
import { mockedStore } from '@/__tests__/utils';
import { STORES } from '@/constants';
vi.mock('@/event-bus', () => ({
nodeViewEventBus: {
emit: vi.fn(),
},
}));
// Mock telemetry
const trackMock = vi.fn();
vi.mock('@/composables/useTelemetry', () => ({
useTelemetry: () => ({
track: trackMock,
}),
}));
// Mock i18n
vi.mock('@/composables/useI18n', () => ({
useI18n: () => ({
baseText: (key: string) => key,
}),
}));
describe('AskAssistantBuild', () => {
const sessionId = faker.string.uuid();
const renderComponent = createComponentRenderer(AskAssistantBuild);
let builderStore: ReturnType<typeof mockedStore<typeof useBuilderStore>>;
beforeEach(() => {
vi.clearAllMocks();
const pinia = createTestingPinia({
initialState: {
[STORES.BUILDER]: {
chatMessages: [],
currentSessionId: sessionId,
streaming: false,
assistantThinkingMessage: undefined,
workflowPrompt: 'Create a workflow',
},
},
});
setActivePinia(pinia);
builderStore = mockedStore(useBuilderStore);
// Mock action implementations
builderStore.initBuilderChat = vi.fn();
builderStore.resetBuilderChat = vi.fn();
builderStore.addAssistantMessages = vi.fn();
builderStore.$onAction = vi.fn().mockReturnValue(vi.fn());
});
describe('rendering', () => {
it('should render component correctly', () => {
const { getByTestId } = renderComponent();
expect(getByTestId('ask-assistant-chat')).toBeInTheDocument();
});
it('should pass correct props to AskAssistantChat component', () => {
renderComponent();
// Basic verification that no methods were called on mount
expect(builderStore.initBuilderChat).not.toHaveBeenCalled();
expect(builderStore.addAssistantMessages).not.toHaveBeenCalled();
});
});
describe('user message handling', () => {
it('should initialize builder chat when a user sends a message', async () => {
const { getByTestId } = renderComponent();
const testMessage = 'Create a workflow to send emails';
// Type message into the chat input
const chatInput = getByTestId('chat-input');
await fireEvent.update(chatInput, testMessage);
// Click the send button
const sendButton = getByTestId('send-message-button');
sendButton.click();
await flushPromises();
expect(builderStore.initBuilderChat).toHaveBeenCalledWith(testMessage, 'chat');
});
});
describe('feedback handling', () => {
beforeEach(() => {
builderStore.chatMessages = [
{
id: faker.string.uuid(),
role: 'assistant',
type: 'workflow-generated',
read: true,
codeSnippet: '{}',
},
{
id: faker.string.uuid(),
role: 'assistant',
type: 'rate-workflow',
read: true,
content: '',
},
];
});
it('should track feedback when user rates the workflow positively', async () => {
const { findByTestId } = renderComponent();
// Find thumbs up button in RateWorkflowMessage component
const thumbsUpButton = await findByTestId('message-thumbs-up-button');
thumbsUpButton.click();
await flushPromises();
expect(trackMock).toHaveBeenCalledWith('User rated workflow generation', {
chat_session_id: sessionId,
helpful: true,
});
});
it('should track feedback when user rates the workflow negatively', async () => {
const { findByTestId } = renderComponent();
// Find thumbs down button in RateWorkflowMessage component
const thumbsDownButton = await findByTestId('message-thumbs-down-button');
thumbsDownButton.click();
await flushPromises();
expect(trackMock).toHaveBeenCalledWith('User rated workflow generation', {
chat_session_id: sessionId,
helpful: false,
});
});
it('should track text feedback when submitted', async () => {
const { findByTestId } = renderComponent();
const feedbackText = 'This workflow is great but could be improved';
// Click thumbs down to show feedback form
const thumbsDownButton = await findByTestId('message-thumbs-down-button');
thumbsDownButton.click();
await flushPromises();
// Type feedback and submit
const feedbackInput = await findByTestId('message-feedback-input');
await fireEvent.update(feedbackInput, feedbackText);
const submitButton = await findByTestId('message-submit-feedback-button');
submitButton.click();
await flushPromises();
expect(trackMock).toHaveBeenCalledWith(
'User submitted workflow generation feedback',
expect.objectContaining({
chat_session_id: sessionId,
feedback: feedbackText,
}),
);
});
});
describe('new workflow generation', () => {
it('should unsubscribe from store actions on unmount', async () => {
const unsubscribeMock = vi.fn();
builderStore.$onAction = vi.fn().mockReturnValue(unsubscribeMock);
const { unmount } = renderComponent();
// Unmount component
unmount();
// Should unsubscribe when component is unmounted
expect(unsubscribeMock).toHaveBeenCalled();
});
});
});

View File

@@ -0,0 +1,230 @@
<script lang="ts" setup>
import { useBuilderStore } from '@/stores/builder.store';
import { useUsersStore } from '@/stores/users.store';
import { computed, watch, ref, onBeforeUnmount } from 'vue';
import AskAssistantChat from '@n8n/design-system/components/AskAssistantChat/AskAssistantChat.vue';
import { useTelemetry } from '@/composables/useTelemetry';
import type { IWorkflowDataUpdate } from '@/Interface';
import { nodeViewEventBus } from '@/event-bus';
import { v4 as uuid } from 'uuid';
import { useI18n } from '@/composables/useI18n';
import { STICKY_NODE_TYPE } from '@/constants';
const emit = defineEmits<{
close: [];
}>();
const builderStore = useBuilderStore();
const usersStore = useUsersStore();
const telemetry = useTelemetry();
const i18n = useI18n();
const helpful = ref(false);
const generationStartTime = ref(0);
const user = computed(() => ({
firstName: usersStore.currentUser?.firstName ?? '',
lastName: usersStore.currentUser?.lastName ?? '',
}));
const workflowGenerated = ref(false);
const loadingMessage = computed(() => builderStore.assistantThinkingMessage);
async function onUserMessage(content: string) {
// If there is no current session running, initialize the support chat session
await builderStore.initBuilderChat(content, 'chat');
}
function fixWorkflowStickiesPosition(workflowData: IWorkflowDataUpdate): IWorkflowDataUpdate {
const STICKY_WIDTH = 480;
const HEADERS_HEIGHT = 40;
const NEW_LINE_HEIGHT = 20;
const CHARACTER_WIDTH = 65;
const NODE_WIDTH = 100;
const stickyNodes = workflowData.nodes?.filter((node) => node.type === STICKY_NODE_TYPE);
const nonStickyNodes = workflowData.nodes?.filter((node) => node.type !== STICKY_NODE_TYPE);
const fixedStickies = stickyNodes?.map((node, index) => {
const content = node.parameters.content?.toString() ?? '';
const newLines = content.match(/\n/g) ?? [];
// Match any markdown heading from # to ###### at the start of a line
const headings = content.match(/^#{1,6} /gm) ?? [];
const headingHeight = headings.length * HEADERS_HEIGHT;
const newLinesHeight = newLines.length * NEW_LINE_HEIGHT;
const contentHeight = (content.length / CHARACTER_WIDTH) * NEW_LINE_HEIGHT;
const height = Math.ceil(headingHeight + newLinesHeight + contentHeight) + NEW_LINE_HEIGHT;
const firstNode = nonStickyNodes?.[0];
const xPos = (firstNode?.position[0] ?? 0) + index * (STICKY_WIDTH + NODE_WIDTH);
return {
...node,
parameters: {
...node.parameters,
height,
width: STICKY_WIDTH,
},
position: [xPos, -1 * (height + 50)] as [number, number],
};
});
return {
...workflowData,
nodes: [...(nonStickyNodes ?? []), ...(fixedStickies ?? [])],
};
}
function onInsertWorkflow(code: string) {
let workflowData: IWorkflowDataUpdate;
try {
workflowData = JSON.parse(code);
} catch (error) {
console.error('Error parsing workflow data', error);
return;
}
telemetry.track('Workflow generated from prompt', {
prompt: builderStore.workflowPrompt,
latency: new Date().getTime() - generationStartTime.value,
workflow_json: code,
});
nodeViewEventBus.emit('importWorkflowData', {
data: fixWorkflowStickiesPosition(workflowData),
tidyUp: true,
});
workflowGenerated.value = true;
builderStore.addAssistantMessages(
[
{
type: 'rate-workflow',
content: i18n.baseText('aiAssistant.builder.feedbackPrompt'),
role: 'assistant',
},
],
uuid(),
);
}
function onNewWorkflow() {
builderStore.resetBuilderChat();
workflowGenerated.value = false;
helpful.value = false;
generationStartTime.value = new Date().getTime();
}
function onThumbsUp() {
helpful.value = true;
telemetry.track('User rated workflow generation', {
chat_session_id: builderStore.currentSessionId,
helpful: helpful.value,
});
}
function onThumbsDown() {
helpful.value = false;
telemetry.track('User rated workflow generation', {
chat_session_id: builderStore.currentSessionId,
helpful: helpful.value,
});
}
function onSubmitFeedback(feedback: string) {
telemetry.track('User submitted workflow generation feedback', {
chat_session_id: builderStore.currentSessionId,
helpful: helpful.value,
feedback,
});
}
watch(
() => builderStore.chatMessages,
(messages) => {
if (workflowGenerated.value) return;
const workflowGeneratedMessage = messages.find((msg) => msg.type === 'workflow-generated');
if (workflowGeneratedMessage) {
onInsertWorkflow(workflowGeneratedMessage.codeSnippet);
}
},
{ deep: true },
);
const unsubscribe = builderStore.$onAction(({ name }) => {
if (name === 'initBuilderChat') {
onNewWorkflow();
}
});
onBeforeUnmount(() => {
unsubscribe();
});
</script>
<template>
<div data-test-id="ask-assistant-chat" tabindex="0" :class="$style.container" @keydown.stop>
<AskAssistantChat
:user="user"
:messages="builderStore.chatMessages"
:streaming="builderStore.streaming"
:loading-message="loadingMessage"
:session-id="builderStore.currentSessionId"
:mode="i18n.baseText('aiAssistant.builder.mode')"
:title="'n8n AI'"
:placeholder="i18n.baseText('aiAssistant.builder.placeholder')"
@close="emit('close')"
@message="onUserMessage"
@thumbs-up="onThumbsUp"
@thumbs-down="onThumbsDown"
@submit-feedback="onSubmitFeedback"
@insert-workflow="onInsertWorkflow"
>
<template #header>
<slot name="header" />
</template>
<template #placeholder>
<n8n-text :class="$style.topText">{{
i18n.baseText('aiAssistant.builder.placeholder')
}}</n8n-text>
</template>
<template v-if="workflowGenerated" #inputPlaceholder>
<div :class="$style.newWorkflowButtonWrapper">
<n8n-button
type="secondary"
size="small"
:class="$style.newWorkflowButton"
@click="onNewWorkflow"
>
{{ i18n.baseText('aiAssistant.builder.generateNew') }}
</n8n-button>
<n8n-text :class="$style.newWorkflowText">
{{ i18n.baseText('aiAssistant.builder.newWorkflowNotice') }}
</n8n-text>
</div>
</template>
</AskAssistantChat>
</div>
</template>
<style lang="scss" module>
.container {
height: 100%;
width: 100%;
}
.topText {
color: var(--color-text-base);
}
.newWorkflowButtonWrapper {
display: flex;
flex-direction: column;
flex-flow: wrap;
gap: var(--spacing-2xs);
background-color: var(--color-background-light);
padding: var(--spacing-xs);
border: 0;
}
.newWorkflowText {
color: var(--color-text-base);
font-size: var(--font-size-2xs);
}
</style>

View File

@@ -0,0 +1,102 @@
<script lang="ts" setup>
import { useBuilderStore } from '@/stores/builder.store';
import { useAssistantStore } from '@/stores/assistant.store';
import { useDebounce } from '@/composables/useDebounce';
import { computed, onBeforeUnmount, ref } from 'vue';
import SlideTransition from '@/components/transitions/SlideTransition.vue';
import AskAssistantBuild from './Agent/AskAssistantBuild.vue';
import AskAssistantChat from './Chat/AskAssistantChat.vue';
const builderStore = useBuilderStore();
const assistantStore = useAssistantStore();
const isBuildMode = ref(builderStore.isAIBuilderEnabled);
const chatWidth = computed(() => {
return isBuildMode.value ? builderStore.chatWidth : assistantStore.chatWidth;
});
function onResize(data: { direction: string; x: number; width: number }) {
builderStore.updateWindowWidth(data.width);
assistantStore.updateWindowWidth(data.width);
}
function onResizeDebounced(data: { direction: string; x: number; width: number }) {
void useDebounce().callDebounced(onResize, { debounceTime: 10, trailing: true }, data);
}
function toggleAssistantMode() {
isBuildMode.value = !isBuildMode.value;
if (isBuildMode.value) {
builderStore.openChat();
} else {
assistantStore.openChat();
}
}
function onClose() {
builderStore.closeChat();
assistantStore.closeChat();
}
const unsubscribeAssistantStore = assistantStore.$onAction(({ name }) => {
// When assistant is opened from error or credentials help
// switch from build mode to chat mode
if (['initErrorHelper', 'initCredHelp', 'openChat'].includes(name)) {
isBuildMode.value = false;
}
});
const unsubscribeBuilderStore = builderStore.$onAction(({ name }) => {
// When assistant is opened from error or credentials help
// switch from build mode to chat mode
if (name === 'initBuilderChat') {
isBuildMode.value = true;
}
});
onBeforeUnmount(() => {
unsubscribeAssistantStore();
unsubscribeBuilderStore();
});
</script>
<template>
<SlideTransition>
<N8nResizeWrapper
v-show="builderStore.isAssistantOpen || assistantStore.isAssistantOpen"
:supported-directions="['left']"
:width="chatWidth"
data-test-id="ask-assistant-sidebar"
@resize="onResizeDebounced"
>
<div :style="{ width: `${chatWidth}px` }" :class="$style.wrapper">
<div :class="$style.assistantContent">
<AskAssistantBuild v-if="isBuildMode" @close="onClose">
<template #header>
<HubSwitcher :is-build-mode="isBuildMode" @toggle="toggleAssistantMode" />
</template>
</AskAssistantBuild>
<AskAssistantChat v-else @close="onClose">
<!-- Header switcher is only visible when AIBuilder is enabled -->
<template v-if="builderStore.isAIBuilderEnabled" #header>
<HubSwitcher :is-build-mode="isBuildMode" @toggle="toggleAssistantMode" />
</template>
</AskAssistantChat>
</div>
</div>
</N8nResizeWrapper>
</SlideTransition>
</template>
<style lang="scss" module>
.wrapper {
height: 100%;
display: flex;
flex-direction: column;
}
.assistantContent {
flex: 1;
overflow: hidden;
}
</style>

View File

@@ -1,15 +1,21 @@
<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';
import { useBuilderStore } from '@/stores/builder.store';
import { useI18n } from '@/composables/useI18n';
const emit = defineEmits<{
close: [];
}>();
const assistantStore = useAssistantStore();
const usersStore = useUsersStore();
const telemetry = useTelemetry();
const builderStore = useBuilderStore();
const i18n = useI18n();
const user = computed(() => ({
firstName: usersStore.currentUser?.firstName ?? '',
@@ -18,14 +24,6 @@ const user = computed(() => ({
const loadingMessage = computed(() => assistantStore.assistantThinkingMessage);
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, isFeedback = false) {
// If there is no current session running, initialize the support chat session
if (!assistantStore.currentSessionId) {
@@ -62,47 +60,36 @@ async function undoCodeDiff(index: number) {
action: 'undo_code_replace',
});
}
function onClose() {
assistantStore.closeChat();
telemetry.track('User closed assistant', { source: 'top-toggle' });
}
</script>
<template>
<SlideTransition>
<N8nResizeWrapper
v-show="assistantStore.isAssistantOpen"
:supported-directions="['left']"
:width="assistantStore.chatWidth"
data-test-id="ask-assistant-sidebar"
@resize="onResizeDebounced"
<div data-test-id="ask-assistant-chat" tabindex="0" class="wrapper" @keydown.stop>
<AskAssistantChat
:user="user"
:messages="assistantStore.chatMessages"
:streaming="assistantStore.streaming"
:loading-message="loadingMessage"
:session-id="assistantStore.currentSessionId"
:title="
builderStore.isAIBuilderEnabled
? i18n.baseText('aiAssistant.n8nAi')
: i18n.baseText('aiAssistant.assistant')
"
@close="emit('close')"
@message="onUserMessage"
@code-replace="onCodeReplace"
@code-undo="undoCodeDiff"
>
<div
:style="{ width: `${assistantStore.chatWidth}px` }"
:class="$style.wrapper"
data-test-id="ask-assistant-chat"
tabindex="0"
@keydown.stop
>
<AskAssistantChat
:user="user"
:messages="assistantStore.chatMessages"
:streaming="assistantStore.streaming"
:loading-message="loadingMessage"
:session-id="assistantStore.currentSessionId"
@close="onClose"
@message="onUserMessage"
@code-replace="onCodeReplace"
@code-undo="undoCodeDiff"
/>
</div>
</N8nResizeWrapper>
</SlideTransition>
<template #header>
<slot name="header" />
</template>
</AskAssistantChat>
</div>
</template>
<style module>
<style scoped>
.wrapper {
height: 100%;
width: 100%;
}
</style>

View File

@@ -3,7 +3,6 @@ import { useI18n } from '@/composables/useI18n';
import { useStyles } from '@/composables/useStyles';
import { useAssistantStore } from '@/stores/assistant.store';
import { useCanvasStore } from '@/stores/canvas.store';
import { useNDVStore } from '@/stores/ndv.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';
@@ -12,8 +11,6 @@ const assistantStore = useAssistantStore();
const i18n = useI18n();
const { APP_Z_INDEXES } = useStyles();
const canvasStore = useCanvasStore();
const ndvStore = useNDVStore();
const bottom = computed(() => (ndvStore.activeNode === null ? canvasStore.panelHeight : 0));
const lastUnread = computed(() => {
const msg = assistantStore.lastUnread;
@@ -44,7 +41,7 @@ const onClick = () => {
v-if="assistantStore.canShowAssistantButtonsOnCanvas && !assistantStore.isAssistantOpen"
:class="$style.container"
data-test-id="ask-assistant-floating-button"
:style="{ bottom: `${bottom}px` }"
:style="{ '--canvas-panel-height-offset': `${canvasStore.panelHeight}px` }"
>
<n8n-tooltip
:z-index="APP_Z_INDEXES.ASK_ASSISTANT_FLOATING_BUTTON_TOOLTIP"
@@ -67,8 +64,8 @@ const onClick = () => {
<style lang="scss" module>
.container {
position: absolute;
margin: var(--spacing-s);
right: 0;
bottom: calc(var(--canvas-panel-height-offset, 0px) + var(--spacing-s));
right: var(--spacing-s);
z-index: var(--z-index-ask-assistant-floating-button);
}

View File

@@ -1,6 +1,6 @@
<script lang="ts" setup>
import { NEW_ASSISTANT_SESSION_MODAL } from '@/constants';
import Modal from '../Modal.vue';
import Modal from '@/components/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';

View File

@@ -0,0 +1,32 @@
<script lang="ts" setup>
import { useI18n } from '@/composables/useI18n';
import { computed } from 'vue';
defineProps<{
isBuildMode: boolean;
}>();
const emit = defineEmits<{
toggle: [value: boolean];
}>();
const i18n = useI18n();
const options = computed(() => [
{ label: i18n.baseText('aiAssistant.assistant'), value: false },
{ label: i18n.baseText('aiAssistant.builder.name'), value: true },
]);
function toggle(value: boolean) {
emit('toggle', value);
}
</script>
<template>
<n8n-radio-buttons
size="small"
:model-value="isBuildMode"
:options="options"
@update:model-value="toggle"
/>
</template>

View File

@@ -69,7 +69,7 @@ import DebugPaywallModal from '@/components/DebugPaywallModal.vue';
import WorkflowHistoryVersionRestoreModal from '@/components/WorkflowHistory/WorkflowHistoryVersionRestoreModal.vue';
import SetupWorkflowCredentialsModal from '@/components/SetupWorkflowCredentialsModal/SetupWorkflowCredentialsModal.vue';
import ProjectMoveResourceModal from '@/components/Projects/ProjectMoveResourceModal.vue';
import NewAssistantSessionModal from '@/components/AskAssistant/NewAssistantSessionModal.vue';
import NewAssistantSessionModal from '@/components/AskAssistant/Chat/NewAssistantSessionModal.vue';
import PromptMfaCodeModal from './PromptMfaCodeModal/PromptMfaCodeModal.vue';
import CommunityPlusEnrollmentModal from '@/components/CommunityPlusEnrollmentModal.vue';
import WorkflowActivationConflictingWebhookModal from '@/components/WorkflowActivationConflictingWebhookModal.vue';

View File

@@ -14,6 +14,7 @@ import { useUIStore } from '@/stores/ui.store';
import { DRAG_EVENT_DATA_KEY } from '@/constants';
import { useAssistantStore } from '@/stores/assistant.store';
import N8nIconButton from '@n8n/design-system/components/N8nIconButton/IconButton.vue';
import { useBuilderStore } from '@/stores/builder.store';
export interface Props {
active?: boolean;
@@ -29,6 +30,7 @@ const emit = defineEmits<{
}>();
const uiStore = useUIStore();
const assistantStore = useAssistantStore();
const builderStore = useBuilderStore();
const { setShowScrim, setActions, setMergeNodes } = useNodeCreatorStore();
const { generateMergedNodesAndActions } = useActionsGenerator();
@@ -43,9 +45,21 @@ const showScrim = computed(() => useNodeCreatorStore().showScrim);
const viewStacksLength = computed(() => useViewStacks().viewStacks.length);
const nodeCreatorInlineStyle = computed(() => {
const rightPosition = assistantStore.isAssistantOpen ? assistantStore.chatWidth : 0;
const rightPosition = getRightOffset();
return { top: `${uiStore.bannersHeight + uiStore.headerHeight}px`, right: `${rightPosition}px` };
});
function getRightOffset() {
if (assistantStore.isAssistantOpen) {
return assistantStore.chatWidth;
}
if (builderStore.isAssistantOpen) {
return builderStore.chatWidth;
}
return 0;
}
function onMouseUpOutside() {
if (state.mousedownInsideEvent) {
const clickEvent = new MouseEvent('click', {

View File

@@ -200,7 +200,7 @@ const renameKeyCode = ' ';
useShortKeyPress(
renameKeyCode,
() => {
if (lastSelectedNode.value) {
if (lastSelectedNode.value && lastSelectedNode.value.id !== CanvasNodeRenderType.AIPrompt) {
emit('update:node:name', lastSelectedNode.value.id);
}
},
@@ -296,7 +296,7 @@ const keyMap = computed(() => {
ctrl_alt_n: () => emit('create:workflow'),
ctrl_enter: () => emit('run:workflow'),
ctrl_s: () => emit('save:workflow'),
shift_alt_t: async () => await onTidyUp('keyboard-shortcut'),
shift_alt_t: async () => await onTidyUp({ source: 'keyboard-shortcut' }),
};
return fullKeymap;
});
@@ -658,16 +658,16 @@ async function onContextMenuAction(action: ContextMenuAction, nodeIds: string[])
case 'change_color':
return props.eventBus.emit('nodes:action', { ids: nodeIds, action: 'update:sticky:color' });
case 'tidy_up':
return await onTidyUp('context-menu');
return await onTidyUp({ source: 'context-menu' });
}
}
async function onTidyUp(source: CanvasLayoutSource) {
async function onTidyUp(payload: { source: CanvasLayoutSource }) {
const applyOnSelection = selectedNodes.value.length > 1;
const target = applyOnSelection ? 'selection' : 'all';
const result = layout(target);
emit('tidy-up', { result, target, source });
emit('tidy-up', { result, target, source: payload.source });
if (!applyOnSelection) {
await nextTick();
@@ -749,14 +749,14 @@ const initialized = ref(false);
onMounted(() => {
props.eventBus.on('fitView', onFitView);
props.eventBus.on('nodes:select', onSelectNodes);
props.eventBus.on('tidyUp', onTidyUp);
window.addEventListener('blur', onWindowBlur);
});
onUnmounted(() => {
props.eventBus.off('fitView', onFitView);
props.eventBus.off('nodes:select', onSelectNodes);
props.eventBus.off('tidyUp', onTidyUp);
window.removeEventListener('blur', onWindowBlur);
});
@@ -900,7 +900,7 @@ provide(CanvasKey, {
@zoom-in="onZoomIn"
@zoom-out="onZoomOut"
@reset-zoom="onResetZoom"
@tidy-up="onTidyUp('canvas-button')"
@tidy-up="onTidyUp({ source: 'canvas-button' })"
/>
<Suspense>

View File

@@ -288,7 +288,9 @@ provide(CanvasNodeKey, {
eventBus: canvasNodeEventBus,
});
const hasToolbar = computed(() => props.data.type !== CanvasNodeRenderType.AddNodes);
const hasToolbar = computed(
() => ![CanvasNodeRenderType.AddNodes, CanvasNodeRenderType.AIPrompt].includes(renderType.value),
);
const showToolbar = computed(() => {
const target = contextMenu.target.value;
@@ -392,6 +394,7 @@ onBeforeUnmount(() => {
@move="onMove"
@update="onUpdate"
@open:contextmenu="onOpenContextMenuFromNode"
@delete="onDelete"
/>
<CanvasNodeTrigger

View File

@@ -3,6 +3,7 @@ import { h, inject } from 'vue';
import CanvasNodeDefault from '@/components/canvas/elements/nodes/render-types/CanvasNodeDefault.vue';
import CanvasNodeStickyNote from '@/components/canvas/elements/nodes/render-types/CanvasNodeStickyNote.vue';
import CanvasNodeAddNodes from '@/components/canvas/elements/nodes/render-types/CanvasNodeAddNodes.vue';
import CanvasNodeAIPrompt from '@/components/canvas/elements/nodes/render-types/CanvasNodeAIPrompt.vue';
import { CanvasNodeKey } from '@/constants';
import { CanvasNodeRenderType } from '@/types';
@@ -19,6 +20,9 @@ const Render = () => {
case CanvasNodeRenderType.AddNodes:
Component = CanvasNodeAddNodes;
break;
case CanvasNodeRenderType.AIPrompt:
Component = CanvasNodeAIPrompt;
break;
default:
Component = CanvasNodeDefault;
}

View File

@@ -0,0 +1,131 @@
<script setup lang="ts">
import { ref, computed } from 'vue';
import { useI18n } from '@/composables/useI18n';
import { useCanvasNode } from '@/composables/useCanvasNode';
import { useBuilderStore } from '@/stores/builder.store';
const emit = defineEmits<{
delete: [id: string];
}>();
const i18n = useI18n();
const { id } = useCanvasNode();
const builderStore = useBuilderStore();
const isPromptVisible = ref(true);
const isFocused = ref(false);
const prompt = ref('');
const hasContent = computed(() => prompt.value.trim().length > 0);
async function onSubmit() {
builderStore.openChat();
emit('delete', id.value);
await builderStore.initBuilderChat(prompt.value, 'canvas');
isPromptVisible.value = false;
}
</script>
<template>
<div v-if="isPromptVisible" :class="$style.container" data-test-id="canvas-ai-prompt">
<div :class="[$style.promptContainer, { [$style.focused]: isFocused }]">
<form :class="$style.form" @submit.prevent="onSubmit">
<n8n-input
v-model="prompt"
:class="$style.form_textarea"
type="textarea"
:disabled="builderStore.streaming"
:placeholder="i18n.baseText('aiAssistant.builder.placeholder')"
:read-only="false"
:rows="15"
@focus="isFocused = true"
@blur="isFocused = false"
@keydown.meta.enter.stop="onSubmit"
/>
<div :class="$style.form_footer">
<n8n-button
native-type="submit"
:disabled="!hasContent || builderStore.streaming"
@keydown.enter="onSubmit"
>{{ i18n.baseText('aiAssistant.builder.buildWorkflow') }}</n8n-button
>
</div>
</form>
</div>
<div :class="$style.or">
<p :class="$style.or_text">or</p>
</div>
</div>
</template>
<style lang="scss" module>
.container {
display: flex;
flex-direction: row;
}
.promptContainer {
--width: 620px;
--height: 150px;
display: flex;
flex-direction: column;
gap: var(--spacing-2xs);
width: var(--width);
height: var(--height);
padding: 0;
border: 1px solid var(--color-foreground-dark);
background-color: var(--color-background-xlight);
border-radius: var(--border-radius-base);
overflow: hidden;
&.focused {
border: 1px solid var(--color-primary);
}
}
.form {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
}
.form_textarea {
display: flex;
flex: 1;
min-height: 0;
overflow: hidden;
border: 0;
:global(.el-textarea__inner) {
height: 100%;
min-height: 0;
overflow-y: auto;
border: 0;
background: transparent;
resize: none;
font-family: var(--font-family);
}
}
.form_footer {
display: flex;
justify-content: flex-end;
padding: var(--spacing-2xs);
}
.or {
display: flex;
justify-content: center;
align-items: center;
width: 60px;
height: 100px;
cursor: auto;
}
.or_text {
font-size: var(--font-size-m);
color: var(--color-text-base);
}
</style>

View File

@@ -13,7 +13,11 @@ import { GRID_SIZE, NODE_SIZE } from '../utils/nodeViewUtils';
export type CanvasLayoutOptions = { id?: string };
export type CanvasLayoutTarget = 'selection' | 'all';
export type CanvasLayoutSource = 'keyboard-shortcut' | 'canvas-button' | 'context-menu';
export type CanvasLayoutSource =
| 'keyboard-shortcut'
| 'canvas-button'
| 'context-menu'
| 'import-workflow-data';
export type CanvasLayoutTargetData = {
nodes: Array<GraphNode<CanvasNodeData>>;
edges: CanvasConnection[];

View File

@@ -15,6 +15,7 @@ import type {
CanvasConnectionPort,
CanvasNode,
CanvasNodeAddNodesRender,
CanvasNodeAIPromptRender,
CanvasNodeData,
CanvasNodeDefaultRender,
CanvasNodeDefaultRenderLabelSize,
@@ -92,6 +93,12 @@ export function useCanvasMapping({
options: {},
};
}
function createAIPromptRenderType(): CanvasNodeAIPromptRender {
return {
type: CanvasNodeRenderType.AIPrompt,
options: {},
};
}
function createDefaultNodeRenderType(node: INodeUi): CanvasNodeDefaultRender {
const nodeType = nodeTypeDescriptionByNodeId.value[node.id];
@@ -130,6 +137,9 @@ export function useCanvasMapping({
case `${CanvasNodeRenderType.AddNodes}`:
acc[node.id] = createAddNodesRenderType();
break;
case `${CanvasNodeRenderType.AIPrompt}`:
acc[node.id] = createAIPromptRenderType();
break;
default:
acc[node.id] = createDefaultNodeRenderType(node);
}

View File

@@ -705,6 +705,7 @@ export const enum STORES {
PUSH = 'push',
COLLABORATION = 'collaboration',
ASSISTANT = 'assistant',
BUILDER = 'builder',
BECOME_TEMPLATE_CREATOR = 'becomeTemplateCreator',
PROJECTS = 'projects',
API_KEYS = 'apiKeys',
@@ -759,11 +760,18 @@ export const SCHEMA_PREVIEW_EXPERIMENT = {
variant: 'variant',
};
export const WORKFLOW_BUILDER_EXPERIMENT = {
name: '30_workflow_builder',
control: 'control',
variant: 'variant',
};
export const EXPERIMENTS_TO_TRACK = [
CREDENTIAL_DOCS_EXPERIMENT.name,
EASY_AI_WORKFLOW_EXPERIMENT.name,
AI_CREDITS_EXPERIMENT.name,
SCHEMA_PREVIEW_EXPERIMENT.name,
WORKFLOW_BUILDER_EXPERIMENT.name,
];
export const WORKFLOW_EVALUATION_EXPERIMENT = '025_workflow_evaluation';

View File

@@ -23,6 +23,9 @@ export interface NodeViewEventBusEvents {
'runWorkflowButton:mouseenter': never;
'runWorkflowButton:mouseleave': never;
/** Command to tidy up the canvas */
tidyUp: never;
}
export const nodeViewEventBus = createEventBus<NodeViewEventBusEvents>();

View File

@@ -160,6 +160,15 @@
"auth.signup.setupYourAccountError": "Problem setting up your account",
"auth.signup.tokenValidationError": "Issue validating invite token",
"aiAssistant.name": "Assistant",
"aiAssistant.n8nAi": "n8n AI",
"aiAssistant.builder.name": "Builder",
"aiAssistant.builder.mode": "AI Builder",
"aiAssistant.builder.placeholder": "What would you like to automate?",
"aiAssistant.builder.generateNew": "Generate new workflow",
"aiAssistant.builder.buildWorkflow": "Build workflow",
"aiAssistant.builder.newWorkflowNotice": "The created workflow will be added to the editor",
"aiAssistant.builder.feedbackPrompt": "Is this workflow helpful?",
"aiAssistant.builder.invalidPrompt": "Prompt validation failed. Please try again with a clearer description of your workflow requirements and supported integrations.",
"aiAssistant.assistant": "AI Assistant",
"aiAssistant.newSessionModal.title.part1": "Start new",
"aiAssistant.newSessionModal.title.part2": "session",

View File

@@ -31,7 +31,7 @@ import { useCredentialsStore } from './credentials.store';
import { useAIAssistantHelpers } from '@/composables/useAIAssistantHelpers';
export const MAX_CHAT_WIDTH = 425;
export const MIN_CHAT_WIDTH = 250;
export const MIN_CHAT_WIDTH = 300;
export const DEFAULT_CHAT_WIDTH = 330;
export const ENABLED_VIEWS = [
...EDITABLE_CANVAS_VIEWS,

View File

@@ -0,0 +1,370 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { setActivePinia, createPinia } from 'pinia';
import { ENABLED_VIEWS, useBuilderStore } from '@/stores/builder.store';
import type { ChatRequest } from '@/types/assistant.types';
import { usePostHog } from './posthog.store';
import { useSettingsStore } from '@/stores/settings.store';
import { defaultSettings } from '../__tests__/defaults';
import { merge } from 'lodash-es';
import { DEFAULT_POSTHOG_SETTINGS } from './posthog.test';
import { WORKFLOW_BUILDER_EXPERIMENT } from '@/constants';
import { reactive } from 'vue';
import * as chatAPI from '@/api/ai';
import * as telemetryModule from '@/composables/useTelemetry';
import type { Telemetry } from '@/plugins/telemetry';
import type { ChatUI } from '@n8n/design-system/types/assistant';
import { DEFAULT_CHAT_WIDTH, MAX_CHAT_WIDTH, MIN_CHAT_WIDTH } from './assistant.store';
let settingsStore: ReturnType<typeof useSettingsStore>;
let posthogStore: ReturnType<typeof usePostHog>;
const apiSpy = vi.spyOn(chatAPI, 'chatWithBuilder');
const track = vi.fn();
const spy = vi.spyOn(telemetryModule, 'useTelemetry');
spy.mockImplementation(
() =>
({
track,
}) as unknown as Telemetry,
);
const setAssistantEnabled = (enabled: boolean) => {
settingsStore.setSettings(
merge({}, defaultSettings, {
aiAssistant: { enabled },
}),
);
};
const currentRouteName = ENABLED_VIEWS[0];
vi.mock('vue-router', () => ({
useRoute: vi.fn(() =>
reactive({
path: '/',
params: {},
name: currentRouteName,
}),
),
useRouter: vi.fn(),
RouterLink: vi.fn(),
}));
describe('AI Builder store', () => {
beforeEach(() => {
vi.clearAllMocks();
setActivePinia(createPinia());
settingsStore = useSettingsStore();
settingsStore.setSettings(
merge({}, defaultSettings, {
posthog: DEFAULT_POSTHOG_SETTINGS,
}),
);
window.posthog = {
init: () => {},
identify: () => {},
};
posthogStore = usePostHog();
posthogStore.init();
track.mockReset();
});
it('initializes with default values', () => {
const builderStore = useBuilderStore();
expect(builderStore.chatWidth).toBe(DEFAULT_CHAT_WIDTH);
expect(builderStore.chatMessages).toEqual([]);
expect(builderStore.chatWindowOpen).toBe(false);
expect(builderStore.streaming).toBe(false);
});
it('can change chat width', () => {
const builderStore = useBuilderStore();
builderStore.updateWindowWidth(400);
expect(builderStore.chatWidth).toBe(400);
});
it('should not allow chat width to be less than the minimal width', () => {
const builderStore = useBuilderStore();
builderStore.updateWindowWidth(100);
expect(builderStore.chatWidth).toBe(MIN_CHAT_WIDTH);
});
it('should not allow chat width to be more than the maximal width', () => {
const builderStore = useBuilderStore();
builderStore.updateWindowWidth(2000);
expect(builderStore.chatWidth).toBe(MAX_CHAT_WIDTH);
});
it('should open chat window', () => {
const builderStore = useBuilderStore();
builderStore.openChat();
expect(builderStore.chatWindowOpen).toBe(true);
});
it('should close chat window', () => {
const builderStore = useBuilderStore();
builderStore.closeChat();
expect(builderStore.chatWindowOpen).toBe(false);
});
it('can add a simple assistant message', () => {
const builderStore = useBuilderStore();
const message: ChatRequest.MessageResponse = {
type: 'message',
role: 'assistant',
text: 'Hello!',
};
builderStore.addAssistantMessages([message], '1');
expect(builderStore.chatMessages.length).toBe(1);
expect(builderStore.chatMessages[0]).toEqual({
id: '1',
type: 'text',
role: 'assistant',
content: 'Hello!',
quickReplies: undefined,
read: true, // Builder messages are always read
});
});
it('can add a workflow step message', () => {
const builderStore = useBuilderStore();
const message: ChatRequest.MessageResponse = {
type: 'workflow-step',
role: 'assistant',
steps: ['Step 1', 'Step 2'],
};
builderStore.addAssistantMessages([message], '1');
expect(builderStore.chatMessages.length).toBe(1);
expect(builderStore.chatMessages[0]).toEqual({
id: '1',
type: 'workflow-step',
role: 'assistant',
steps: ['Step 1', 'Step 2'],
read: true,
});
});
it('can add a workflow-generated message', () => {
const builderStore = useBuilderStore();
const message: ChatRequest.MessageResponse = {
type: 'workflow-generated',
role: 'assistant',
codeSnippet: '{"nodes":[],"connections":[]}',
};
builderStore.addAssistantMessages([message], '1');
expect(builderStore.chatMessages.length).toBe(1);
expect(builderStore.chatMessages[0]).toEqual({
id: '1',
type: 'workflow-generated',
role: 'assistant',
codeSnippet: '{"nodes":[],"connections":[]}',
read: true,
});
});
it('can add a rate-workflow message', () => {
const builderStore = useBuilderStore();
const message: ChatRequest.MessageResponse = {
type: 'rate-workflow',
role: 'assistant',
content: 'How was the workflow?',
};
builderStore.addAssistantMessages([message], '1');
expect(builderStore.chatMessages.length).toBe(1);
expect(builderStore.chatMessages[0]).toEqual({
id: '1',
type: 'rate-workflow',
role: 'assistant',
content: 'How was the workflow?',
read: true,
});
});
it('should reset builder chat session', () => {
const builderStore = useBuilderStore();
const message: ChatRequest.MessageResponse = {
type: 'message',
role: 'assistant',
text: 'Hello!',
quickReplies: [
{ text: 'Yes', type: 'text' },
{ text: 'No', type: 'text' },
],
};
builderStore.addAssistantMessages([message], '1');
expect(builderStore.chatMessages.length).toBe(1);
builderStore.resetBuilderChat();
expect(builderStore.chatMessages).toEqual([]);
expect(builderStore.currentSessionId).toBeUndefined();
});
it('should not show builder if disabled in settings', () => {
const builderStore = useBuilderStore();
setAssistantEnabled(false);
expect(builderStore.isAssistantEnabled).toBe(false);
expect(builderStore.canShowAssistant).toBe(false);
expect(builderStore.canShowAssistantButtonsOnCanvas).toBe(false);
});
it('should show builder if all conditions are met', () => {
const builderStore = useBuilderStore();
setAssistantEnabled(true);
expect(builderStore.isAssistantEnabled).toBe(true);
expect(builderStore.canShowAssistant).toBe(true);
expect(builderStore.canShowAssistantButtonsOnCanvas).toBe(true);
});
// Split into two separate tests to avoid caching issues with computed properties
it('should return true when experiment flag is set to variant', () => {
const builderStore = useBuilderStore();
vi.spyOn(posthogStore, 'getVariant').mockReturnValue(WORKFLOW_BUILDER_EXPERIMENT.variant);
expect(builderStore.isAIBuilderEnabled).toBe(true);
});
it('should return false when experiment flag is set to control', () => {
const builderStore = useBuilderStore();
vi.spyOn(posthogStore, 'getVariant').mockReturnValue(WORKFLOW_BUILDER_EXPERIMENT.control);
expect(builderStore.isAIBuilderEnabled).toBe(false);
});
it('should initialize builder chat session with prompt', async () => {
const builderStore = useBuilderStore();
const mockSessionId = 'test-session-id';
apiSpy.mockImplementation((_ctx, _payload, onMessage, onDone) => {
onMessage({
messages: [
{
type: 'message',
role: 'assistant',
text: 'How can I help you build a workflow?',
},
],
sessionId: mockSessionId,
});
onDone();
});
await builderStore.initBuilderChat('I want to build a workflow', 'chat');
expect(apiSpy).toHaveBeenCalled();
expect(builderStore.currentSessionId).toEqual(mockSessionId);
expect(builderStore.chatMessages.length).toBe(2); // user message + assistant response
expect(builderStore.chatMessages[0].role).toBe('user');
expect(builderStore.chatMessages[1].role).toBe('assistant');
});
it('should send a follow-up message in an existing session', async () => {
const builderStore = useBuilderStore();
const mockSessionId = 'test-session-id';
// Setup initial session
apiSpy.mockImplementationOnce((_ctx, _payload, onMessage, onDone) => {
onMessage({
messages: [
{
type: 'message',
role: 'assistant',
text: 'How can I help you build a workflow?',
},
],
sessionId: mockSessionId,
});
onDone();
});
// Setup follow-up message response
apiSpy.mockImplementationOnce((_ctx, _payload, onMessage, onDone) => {
onMessage({
messages: [
{
type: 'message',
role: 'assistant',
text: 'Here are some workflow ideas',
},
],
sessionId: mockSessionId,
});
onDone();
});
await builderStore.initBuilderChat('I want to build a workflow', 'chat');
// Should be 2 messages now (user question + assistant response)
expect(builderStore.chatMessages.length).toBe(2);
// Send a follow-up message
await builderStore.sendMessage({ text: 'Generate a workflow for me' });
const thirdMessage = builderStore.chatMessages[2] as ChatUI.TextMessage;
const fourthMessage = builderStore.chatMessages[3] as ChatUI.TextMessage;
// Should be 4 messages now (2 initial + user follow-up + assistant response)
expect(builderStore.chatMessages.length).toBe(4);
expect(thirdMessage.role).toBe('user');
expect(thirdMessage.type).toBe('text');
expect(thirdMessage.content).toBe('Generate a workflow for me');
expect(fourthMessage.role).toBe('assistant');
expect(fourthMessage.type).toBe('text');
expect(fourthMessage.content).toBe('Here are some workflow ideas');
});
it('should properly handle errors in chat session', async () => {
const builderStore = useBuilderStore();
// Simulate an error response
apiSpy.mockImplementationOnce((_ctx, _payload, _onMessage, _onDone, onError) => {
onError(new Error('An API error occurred'));
});
await builderStore.initBuilderChat('I want to build a workflow', 'chat');
// Should have user message + error message
expect(builderStore.chatMessages.length).toBe(2);
expect(builderStore.chatMessages[0].role).toBe('user');
expect(builderStore.chatMessages[1].type).toBe('error');
// Error message should have a retry function
const errorMessage = builderStore.chatMessages[1] as ChatUI.ErrorMessage;
expect(errorMessage.retry).toBeDefined();
// Set up a successful response for the retry
apiSpy.mockImplementationOnce((_ctx, _payload, onMessage, onDone) => {
onMessage({
messages: [
{
type: 'message',
role: 'assistant',
text: 'I can help you build a workflow',
},
],
sessionId: 'new-session',
});
onDone();
});
// Retry the failed request
await errorMessage.retry?.();
// Should now have just the user message and success message
expect(builderStore.chatMessages.length).toBe(2);
expect(builderStore.chatMessages[0].role).toBe('user');
expect(builderStore.chatMessages[1].type).toBe('text');
expect((builderStore.chatMessages[1] as ChatUI.TextMessage).content).toBe(
'I can help you build a workflow',
);
});
});

View File

@@ -0,0 +1,365 @@
import { chatWithBuilder } from '@/api/ai';
import type { VIEWS } from '@/constants';
import { EDITABLE_CANVAS_VIEWS, STORES, WORKFLOW_BUILDER_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 '@n8n/utils/assert';
import { useI18n } from '@/composables/useI18n';
import { useTelemetry } from '@/composables/useTelemetry';
import { useUIStore } from './ui.store';
import { usePostHog } from './posthog.store';
import { useNodeTypesStore } from './nodeTypes.store';
import { DEFAULT_CHAT_WIDTH, MAX_CHAT_WIDTH, MIN_CHAT_WIDTH } from './assistant.store';
export const ENABLED_VIEWS = [...EDITABLE_CANVAS_VIEWS];
export const useBuilderStore = defineStore(STORES.BUILDER, () => {
// Core state
const chatWidth = ref<number>(DEFAULT_CHAT_WIDTH);
const chatMessages = ref<ChatUI.AssistantMessage[]>([]);
const chatWindowOpen = ref<boolean>(false);
const streaming = ref<boolean>(false);
const currentSessionId = ref<string | undefined>();
const assistantThinkingMessage = ref<string | undefined>();
// Store dependencies
const settings = useSettingsStore();
const rootStore = useRootStore();
const usersStore = useUsersStore();
const uiStore = useUIStore();
const route = useRoute();
const locale = useI18n();
const telemetry = useTelemetry();
const posthogStore = usePostHog();
const nodeTypesStore = useNodeTypesStore();
// Computed properties
const isAssistantEnabled = computed(() => settings.isAiAssistantEnabled);
const workflowPrompt = computed(() => {
const firstUserMessage = chatMessages.value.find(
(msg) => msg.role === 'user' && msg.type === 'text',
) as ChatUI.TextMessage;
return firstUserMessage?.content;
});
const canShowAssistant = computed(
() => isAssistantEnabled.value && ENABLED_VIEWS.includes(route.name as VIEWS),
);
const canShowAssistantButtonsOnCanvas = computed(
() => isAssistantEnabled.value && EDITABLE_CANVAS_VIEWS.includes(route.name as VIEWS),
);
const isAssistantOpen = computed(() => canShowAssistant.value && chatWindowOpen.value);
const isAIBuilderEnabled = computed(() => {
return (
posthogStore.getVariant(WORKFLOW_BUILDER_EXPERIMENT.name) ===
WORKFLOW_BUILDER_EXPERIMENT.variant
);
});
// No need to track unread messages in the AI Builder
const unreadCount = computed(() => 0);
// Chat management functions
function resetBuilderChat() {
clearMessages();
currentSessionId.value = undefined;
assistantThinkingMessage.value = undefined;
}
function openChat() {
chatWindowOpen.value = true;
chatMessages.value = chatMessages.value.map((msg) => ({ ...msg, read: true }));
uiStore.appGridDimensions = {
...uiStore.appGridDimensions,
width: window.innerWidth - chatWidth.value,
};
}
function closeChat() {
chatWindowOpen.value = false;
// Looks smoother if we wait for slide animation to finish before updating the grid width
setTimeout(() => {
uiStore.appGridDimensions = {
...uiStore.appGridDimensions,
width: window.innerWidth,
};
}, 200);
}
function clearMessages() {
chatMessages.value = [];
}
function updateWindowWidth(width: number) {
chatWidth.value = Math.min(Math.max(width, MIN_CHAT_WIDTH), MAX_CHAT_WIDTH);
}
// Message handling functions
function addAssistantMessages(newMessages: ChatRequest.MessageResponse[], id: string) {
const read = true; // Always mark as read in builder
const messages = [...chatMessages.value].filter(
(msg) => !(msg.id === id && msg.role === 'assistant'),
);
assistantThinkingMessage.value = undefined;
newMessages.forEach((msg) => {
if (msg.type === 'message') {
messages.push({
id,
type: 'text',
role: 'assistant',
content: msg.text,
quickReplies: msg.quickReplies,
codeSnippet: msg.codeSnippet,
read,
});
} else if (msg.type === 'workflow-step' && 'steps' in msg) {
messages.push({
id,
type: 'workflow-step',
role: 'assistant',
steps: msg.steps,
read,
});
} else if (msg.type === 'prompt-validation' && !msg.isWorkflowPrompt) {
messages.push({
id,
role: 'assistant',
type: 'error',
content: locale.baseText('aiAssistant.builder.invalidPrompt'),
read: true,
});
} else if (msg.type === 'workflow-node' && 'nodes' in msg) {
const mappedNodes = msg.nodes.map(
(node) => nodeTypesStore.getNodeType(node)?.displayName ?? node,
);
messages.push({
id,
type: 'workflow-node',
role: 'assistant',
nodes: mappedNodes,
read,
});
} else if (msg.type === 'workflow-composed' && 'nodes' in msg) {
messages.push({
id,
type: 'workflow-composed',
role: 'assistant',
nodes: msg.nodes,
read,
});
} else if (msg.type === 'workflow-generated' && 'codeSnippet' in msg) {
messages.push({
id,
type: 'workflow-generated',
role: 'assistant',
codeSnippet: msg.codeSnippet,
read,
});
} else if (msg.type === 'rate-workflow') {
messages.push({
id,
type: 'rate-workflow',
role: 'assistant',
content: msg.content,
read,
});
}
});
chatMessages.value = messages;
}
function addAssistantError(content: string, id: string, retry?: () => Promise<void>) {
chatMessages.value.push({
id,
role: 'assistant',
type: 'error',
content,
read: true,
retry,
});
}
function addLoadingAssistantMessage(message: string) {
assistantThinkingMessage.value = message;
}
function addUserMessage(content: string, id: string) {
chatMessages.value.push({
id,
role: 'user',
type: 'text',
content,
read: true,
});
}
function stopStreaming() {
streaming.value = false;
}
// Error handling
function handleServiceError(e: unknown, id: string, retry?: () => Promise<void>) {
assert(e instanceof Error);
stopStreaming();
assistantThinkingMessage.value = undefined;
addAssistantError(
locale.baseText('aiAssistant.serviceError.message', { interpolate: { message: e.message } }),
id,
retry,
);
telemetry.track('Workflow generation errored', {
error: e.message,
prompt: workflowPrompt.value,
});
}
// API interaction
function getRandomId() {
return `${Math.floor(Math.random() * 100000000)}`;
}
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: 'workflow-generation',
},
{ withPostHog: true },
);
} else if (currentSessionId.value !== response.sessionId) {
// Ignore messages from other sessions
return;
}
addAssistantMessages(response.messages, id);
}
function onDoneStreaming() {
stopStreaming();
}
// Core API functions
async function initBuilderChat(userMessage: string, source: 'chat' | 'canvas') {
telemetry.track(
'User submitted workflow prompt',
{
source,
prompt: userMessage,
},
{ withPostHog: true },
);
resetBuilderChat();
const id = getRandomId();
addUserMessage(userMessage, id);
addLoadingAssistantMessage(locale.baseText('aiAssistant.thinkingSteps.thinking'));
openChat();
streaming.value = true;
const payload: ChatRequest.InitBuilderChat = {
role: 'user',
type: 'init-builder-chat',
user: {
firstName: usersStore.currentUser?.firstName ?? '',
},
question: userMessage,
};
chatWithBuilder(
rootStore.restApiContext,
{
payload,
},
(msg) => onEachStreamingMessage(msg, id),
() => onDoneStreaming(),
(e) => handleServiceError(e, id, async () => await initBuilderChat(userMessage, 'chat')),
);
}
async function sendMessage(
chatMessage: Pick<ChatRequest.UserChatMessage, 'text' | 'quickReplyType'>,
) {
if (streaming.value) {
return;
}
const id = getRandomId();
const retry = async () => {
chatMessages.value = chatMessages.value.filter((msg) => msg.id !== id);
await sendMessage(chatMessage);
};
try {
addUserMessage(chatMessage.text, id);
addLoadingAssistantMessage(locale.baseText('aiAssistant.thinkingSteps.thinking'));
streaming.value = true;
assert(currentSessionId.value);
chatWithBuilder(
rootStore.restApiContext,
{
payload: {
role: 'user',
type: 'message',
text: chatMessage.text,
quickReplyType: chatMessage.quickReplyType,
},
sessionId: currentSessionId.value,
},
(msg) => onEachStreamingMessage(msg, id),
() => onDoneStreaming(),
(e) => handleServiceError(e, id, retry),
);
} catch (e: unknown) {
// in case of assert
handleServiceError(e, id, retry);
}
}
// Reset on route change
watch(route, () => {
resetBuilderChat();
});
// Public API
return {
// State
isAssistantEnabled,
canShowAssistantButtonsOnCanvas,
chatWidth,
chatMessages,
unreadCount,
streaming,
isAssistantOpen,
canShowAssistant,
currentSessionId,
assistantThinkingMessage,
chatWindowOpen,
isAIBuilderEnabled,
workflowPrompt,
// Methods
updateWindowWidth,
closeChat,
openChat,
resetBuilderChat,
initBuilderChat,
sendMessage,
addAssistantMessages,
handleServiceError,
};
});

View File

@@ -63,6 +63,17 @@ export namespace ChatRequest {
question: string;
}
export interface InitBuilderChat {
role: 'user';
type: 'init-builder-chat';
user: {
firstName: string;
};
context?: UserContext & WorkflowContext;
workflowContext?: WorkflowContext;
question: string;
}
export interface InitCredHelp {
role: 'user';
type: 'init-cred-help';
@@ -116,7 +127,7 @@ export namespace ChatRequest {
export type RequestPayload =
| {
payload: InitErrorHelper | InitSupportChat | InitCredHelp;
payload: InitErrorHelper | InitSupportChat | InitCredHelp | InitBuilderChat;
}
| {
payload: EventRequestPayload | UserChatMessage;
@@ -173,6 +184,44 @@ export namespace ChatRequest {
step: string;
}
interface WorkflowStepMessage {
role: 'assistant';
type: 'workflow-step';
steps: string[];
}
interface WorkflowNodeMessage {
role: 'assistant';
type: 'workflow-node';
nodes: string[];
}
interface WorkflowPromptValidationMessage {
role: 'assistant';
type: 'prompt-validation';
isWorkflowPrompt: boolean;
}
interface WorkflowComposedMessage {
role: 'assistant';
type: 'workflow-composed';
nodes: Array<{
parameters: Record<string, unknown>;
type: string;
name: string;
position: [number, number];
}>;
}
interface WorkflowGeneratedMessage {
role: 'assistant';
type: 'workflow-generated';
codeSnippet: string;
}
interface RateWorkflowMessage {
role: 'assistant';
type: 'rate-workflow';
content: string;
}
export type MessageResponse =
| ((
| AssistantChatMessage
@@ -180,6 +229,12 @@ export namespace ChatRequest {
| AssistantSummaryMessage
| AgentChatMessage
| AgentThinkingStep
| WorkflowStepMessage
| WorkflowNodeMessage
| WorkflowComposedMessage
| WorkflowPromptValidationMessage
| WorkflowGeneratedMessage
| RateWorkflowMessage
) & {
quickReplies?: QuickReplyOption[];
})

View File

@@ -11,6 +11,7 @@ import type {
import type { IExecutionResponse, INodeUi } from '@/Interface';
import type { ComputedRef, Ref } from 'vue';
import type { EventBus } from '@n8n/utils/event-bus';
import type { CanvasLayoutSource } from '@/composables/useCanvasLayout';
import type { NodeIconSource } from '../utils/nodeIcon';
export const enum CanvasConnectionMode {
@@ -44,6 +45,7 @@ export const enum CanvasNodeRenderType {
Default = 'default',
StickyNote = 'n8n-nodes-base.stickyNote',
AddNodes = 'n8n-nodes-internal.addNodes',
AIPrompt = 'n8n-nodes-base.aiPrompt',
}
export type CanvasNodeDefaultRenderLabelSize = 'small' | 'medium' | 'large';
@@ -81,6 +83,11 @@ export type CanvasNodeAddNodesRender = {
options: Record<string, never>;
};
export type CanvasNodeAIPromptRender = {
type: CanvasNodeRenderType.AIPrompt;
options: Record<string, never>;
};
export type CanvasNodeStickyNoteRender = {
type: CanvasNodeRenderType.StickyNote;
options: Partial<{
@@ -122,7 +129,11 @@ export interface CanvasNodeData {
iterations: number;
visible: boolean;
};
render: CanvasNodeDefaultRender | CanvasNodeStickyNoteRender | CanvasNodeAddNodesRender;
render:
| CanvasNodeDefaultRender
| CanvasNodeStickyNoteRender
| CanvasNodeAddNodesRender
| CanvasNodeAIPromptRender;
}
export type CanvasNode = Node<CanvasNodeData>;
@@ -170,6 +181,7 @@ export type CanvasEventBusEvents = {
action: keyof CanvasNodeEventBusEvents;
payload?: CanvasNodeEventBusEvents[keyof CanvasNodeEventBusEvents];
};
tidyUp: { source: CanvasLayoutSource };
};
export interface CanvasNodeInjectionData {

View File

@@ -114,6 +114,7 @@ import { getEasyAiWorkflowJson } from '@/utils/easyAiWorkflowUtils';
import type { CanvasLayoutEvent } from '@/composables/useCanvasLayout';
import { useClearExecutionButtonVisible } from '@/composables/useClearExecutionButtonVisible';
import { LOGS_PANEL_STATE } from '@/components/CanvasChat/types/logs';
import { useBuilderStore } from '@/stores/builder.store';
import { useFoldersStore } from '@/stores/folders.store';
defineOptions({
@@ -165,6 +166,7 @@ const tagsStore = useTagsStore();
const pushConnectionStore = usePushConnectionStore();
const ndvStore = useNDVStore();
const templatesStore = useTemplatesStore();
const builderStore = useBuilderStore();
const foldersStore = useFoldersStore();
const canvasEventBus = createEventBus<CanvasEventBusEvents>();
@@ -228,6 +230,7 @@ const isExecutionPreview = ref(false);
const canOpenNDV = ref(true);
const hideNodeIssues = ref(false);
const fallbackNodes = ref<INodeUi[]>([]);
const initializedWorkflowId = ref<string | undefined>();
const workflowId = computed(() => {
@@ -254,21 +257,6 @@ const isCanvasReadOnly = computed(() => {
);
});
const fallbackNodes = computed<INodeUi[]>(() =>
isLoading.value || isCanvasReadOnly.value
? []
: [
{
id: CanvasNodeRenderType.AddNodes,
name: CanvasNodeRenderType.AddNodes,
type: CanvasNodeRenderType.AddNodes,
typeVersion: 1,
position: [0, 0],
parameters: {},
},
],
);
const showFallbackNodes = computed(() => triggerNodes.value.length === 0);
const keyBindingsEnabled = computed(() => {
@@ -632,7 +620,12 @@ function onRevertNodePosition({ nodeName, position }: { nodeName: string; positi
}
function onDeleteNode(id: string) {
deleteNode(id, { trackHistory: true });
const matchedFallbackNode = fallbackNodes.value.findIndex((node) => node.id === id);
if (matchedFallbackNode >= 0) {
fallbackNodes.value.splice(matchedFallbackNode, 1);
} else {
deleteNode(id, { trackHistory: true });
}
}
function onDeleteNodes(ids: string[]) {
@@ -972,6 +965,11 @@ async function onImportWorkflowDataEvent(data: IDataObject) {
fitView();
selectNodes(workflowData.nodes?.map((node) => node.id) ?? []);
if (data.tidyUp) {
setTimeout(() => {
canvasEventBus.emit('tidyUp', { source: 'import-workflow-data' });
}, 0);
}
}
async function onImportWorkflowUrlEvent(data: IDataObject) {
@@ -1673,6 +1671,37 @@ watch(
},
);
watch(
() => {
return isLoading.value || isCanvasReadOnly.value || editableWorkflow.value.nodes.length !== 0;
},
(isReadOnlyOrLoading) => {
const defaultFallbackNodes: INodeUi[] = [
{
id: CanvasNodeRenderType.AddNodes,
name: CanvasNodeRenderType.AddNodes,
type: CanvasNodeRenderType.AddNodes,
typeVersion: 1,
position: [0, 0],
parameters: {},
},
];
if (builderStore.isAIBuilderEnabled && builderStore.isAssistantEnabled) {
defaultFallbackNodes.unshift({
id: CanvasNodeRenderType.AIPrompt,
name: CanvasNodeRenderType.AIPrompt,
type: CanvasNodeRenderType.AIPrompt,
typeVersion: 1,
position: [-690, -15],
parameters: {},
});
}
fallbackNodes.value = isReadOnlyOrLoading ? [] : defaultFallbackNodes;
},
);
// This keeps the selected node in sync if the URL is updated
watch(
() => route.params.nodeId,