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

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