mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-17 18:12:04 +00:00
feat: AI workflow builder front-end (no-changelog) (#14820)
Co-authored-by: Giulio Andreini <g.andreini@gmail.com>
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
@@ -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>
|
||||
@@ -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';
|
||||
|
||||
@@ -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', {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user