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

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