mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-17 18:12:04 +00:00
feat(editor): Add AI prompt box with workflow suggestions for canvas (no-changelog) (#17741)
This commit is contained in:
@@ -174,14 +174,21 @@
|
||||
"aiAssistant.n8nAi": "n8n AI",
|
||||
"aiAssistant.builder.name": "Builder",
|
||||
"aiAssistant.builder.mode": "AI Builder",
|
||||
"aiAssistant.builder.placeholder": "What would you like to automate?",
|
||||
"aiAssistant.builder.placeholder": "Ask n8n to build...",
|
||||
"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.builder.workflowParsingError.title": "Unable to insert workflow",
|
||||
"aiAssistant.builder.workflowParsingError.content": "The workflow returned by AI could not be parsed. Please try again.",
|
||||
"aiAssistant.builder.canvasPrompt.buildWorkflow": "Create workflow",
|
||||
"aiAssistant.builder.canvasPrompt.title": "What would you like to automate?",
|
||||
"aiAssistant.builder.canvasPrompt.confirmTitle": "Replace current prompt?",
|
||||
"aiAssistant.builder.canvasPrompt.confirmMessage": "This will replace your current prompt. Are you sure?",
|
||||
"aiAssistant.builder.canvasPrompt.confirmButton": "Replace",
|
||||
"aiAssistant.builder.canvasPrompt.cancelButton": "Cancel",
|
||||
"aiAssistant.builder.canvasPrompt.startManually.title": "Start manually",
|
||||
"aiAssistant.builder.canvasPrompt.startManually.subTitle": "Add the first node",
|
||||
"aiAssistant.assistant": "AI Assistant",
|
||||
"aiAssistant.newSessionModal.title.part1": "Start new",
|
||||
"aiAssistant.newSessionModal.title.part2": "session",
|
||||
|
||||
@@ -177,6 +177,7 @@ export interface INodeUi extends INode {
|
||||
issues?: INodeIssues;
|
||||
name: string;
|
||||
pinData?: IDataObject;
|
||||
draggable?: boolean;
|
||||
}
|
||||
|
||||
export interface INodeTypesMaxCount {
|
||||
|
||||
@@ -40,6 +40,7 @@ export const mockNode = ({
|
||||
issues = undefined,
|
||||
typeVersion = 1,
|
||||
parameters = {},
|
||||
draggable = true,
|
||||
}: {
|
||||
id?: INodeUi['id'];
|
||||
name: INodeUi['name'];
|
||||
@@ -49,7 +50,9 @@ export const mockNode = ({
|
||||
issues?: INodeIssues;
|
||||
typeVersion?: INodeUi['typeVersion'];
|
||||
parameters?: INodeUi['parameters'];
|
||||
}) => mock<INodeUi>({ id, name, type, position, disabled, issues, typeVersion, parameters });
|
||||
draggable?: INodeUi['draggable'];
|
||||
}) =>
|
||||
mock<INodeUi>({ id, name, type, position, disabled, issues, typeVersion, parameters, draggable });
|
||||
|
||||
export const mockNodeTypeDescription = ({
|
||||
name = SET_NODE_TYPE,
|
||||
|
||||
@@ -108,7 +108,11 @@ const classes = computed(() => ({
|
||||
const renderType = computed<CanvasNodeRenderType>(() => props.data.render.type);
|
||||
|
||||
const dataTestId = computed(() =>
|
||||
[CanvasNodeRenderType.StickyNote, CanvasNodeRenderType.AddNodes].includes(renderType.value)
|
||||
[
|
||||
CanvasNodeRenderType.StickyNote,
|
||||
CanvasNodeRenderType.AddNodes,
|
||||
CanvasNodeRenderType.AIPrompt,
|
||||
].includes(renderType.value)
|
||||
? undefined
|
||||
: 'canvas-node',
|
||||
);
|
||||
|
||||
@@ -0,0 +1,334 @@
|
||||
import { createComponentRenderer } from '@/__tests__/render';
|
||||
import { createTestingPinia } from '@pinia/testing';
|
||||
import { setActivePinia } from 'pinia';
|
||||
import { fireEvent, waitFor } from '@testing-library/vue';
|
||||
import { ref } from 'vue';
|
||||
import CanvasNodeAIPrompt from '@/components/canvas/elements/nodes/render-types/CanvasNodeAIPrompt.vue';
|
||||
import { MODAL_CONFIRM, NODE_CREATOR_OPEN_SOURCES } from '@/constants';
|
||||
import { WORKFLOW_SUGGESTIONS } from '@/constants/workflowSuggestions';
|
||||
|
||||
// Mock stores
|
||||
const streaming = ref(false);
|
||||
const openChat = vi.fn();
|
||||
const sendChatMessage = vi.fn();
|
||||
vi.mock('@/stores/builder.store', () => {
|
||||
return {
|
||||
useBuilderStore: vi.fn(() => ({
|
||||
get streaming() {
|
||||
return streaming.value;
|
||||
},
|
||||
openChat,
|
||||
sendChatMessage,
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
const isNewWorkflow = ref(false);
|
||||
vi.mock('@/stores/workflows.store', () => {
|
||||
return {
|
||||
useWorkflowsStore: vi.fn(() => ({
|
||||
get isNewWorkflow() {
|
||||
return isNewWorkflow.value;
|
||||
},
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
const openNodeCreatorForTriggerNodes = vi.fn();
|
||||
vi.mock('@/stores/nodeCreator.store', () => ({
|
||||
useNodeCreatorStore: vi.fn(() => ({
|
||||
openNodeCreatorForTriggerNodes,
|
||||
})),
|
||||
}));
|
||||
|
||||
// Mock composables
|
||||
const saveCurrentWorkflow = vi.fn();
|
||||
vi.mock('@/composables/useWorkflowSaving', () => ({
|
||||
useWorkflowSaving: vi.fn(() => ({
|
||||
saveCurrentWorkflow,
|
||||
})),
|
||||
}));
|
||||
|
||||
const confirmMock = vi.fn();
|
||||
vi.mock('@/composables/useMessage', () => ({
|
||||
useMessage: vi.fn(() => ({
|
||||
confirm: confirmMock,
|
||||
})),
|
||||
}));
|
||||
|
||||
const telemetryTrack = vi.fn();
|
||||
vi.mock('@/composables/useTelemetry', () => ({
|
||||
useTelemetry: vi.fn(() => ({
|
||||
track: telemetryTrack,
|
||||
})),
|
||||
}));
|
||||
|
||||
// Mock router
|
||||
vi.mock('vue-router', () => ({
|
||||
useRouter: vi.fn(),
|
||||
RouterLink: vi.fn(),
|
||||
}));
|
||||
|
||||
const renderComponent = createComponentRenderer(CanvasNodeAIPrompt);
|
||||
|
||||
describe('CanvasNodeAIPrompt', () => {
|
||||
beforeEach(() => {
|
||||
const pinia = createTestingPinia();
|
||||
setActivePinia(pinia);
|
||||
|
||||
// Reset all mocks
|
||||
vi.clearAllMocks();
|
||||
streaming.value = false;
|
||||
isNewWorkflow.value = false;
|
||||
});
|
||||
|
||||
// Snapshot Test
|
||||
it('should render component correctly', () => {
|
||||
const { html } = renderComponent();
|
||||
expect(html()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
describe('disabled state', () => {
|
||||
it('should disable textarea when builder is streaming', () => {
|
||||
streaming.value = true;
|
||||
const { container } = renderComponent();
|
||||
|
||||
const textarea = container.querySelector('textarea');
|
||||
expect(textarea).toHaveAttribute('disabled');
|
||||
});
|
||||
|
||||
it('should disable submit button when builder is streaming', () => {
|
||||
streaming.value = true;
|
||||
const { container } = renderComponent();
|
||||
|
||||
const submitButton = container.querySelector('button[type="submit"]');
|
||||
expect(submitButton).toHaveAttribute('disabled');
|
||||
});
|
||||
|
||||
it('should disable submit button when prompt is empty', () => {
|
||||
const { container } = renderComponent();
|
||||
|
||||
const submitButton = container.querySelector('button[type="submit"]');
|
||||
expect(submitButton).toHaveAttribute('disabled');
|
||||
});
|
||||
});
|
||||
|
||||
describe('form submission', () => {
|
||||
it('should submit form on Cmd+Enter keyboard shortcut', async () => {
|
||||
const { container } = renderComponent();
|
||||
const textarea = container.querySelector('textarea');
|
||||
|
||||
if (!textarea) throw new Error('Textarea not found');
|
||||
|
||||
// Type in textarea
|
||||
await fireEvent.update(textarea, 'Test prompt');
|
||||
|
||||
// Fire Cmd+Enter
|
||||
await fireEvent.keyDown(textarea, { key: 'Enter', metaKey: true });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(openChat).toHaveBeenCalled();
|
||||
expect(sendChatMessage).toHaveBeenCalledWith({
|
||||
text: 'Test prompt',
|
||||
source: 'canvas',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should not submit when prompt is empty', async () => {
|
||||
const { container } = renderComponent();
|
||||
const form = container.querySelector('form');
|
||||
|
||||
if (!form) throw new Error('Form not found');
|
||||
|
||||
await fireEvent.submit(form);
|
||||
|
||||
expect(openChat).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not submit when streaming', async () => {
|
||||
streaming.value = true;
|
||||
const { container } = renderComponent();
|
||||
const textarea = container.querySelector('textarea');
|
||||
const form = container.querySelector('form');
|
||||
|
||||
if (!textarea || !form) throw new Error('Elements not found');
|
||||
|
||||
// Even with content, submission should be blocked
|
||||
await fireEvent.update(textarea, 'Test prompt');
|
||||
await fireEvent.submit(form);
|
||||
|
||||
expect(openChat).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should open AI assistant panel and send message on submit', async () => {
|
||||
const { container } = renderComponent();
|
||||
const textarea = container.querySelector('textarea');
|
||||
const form = container.querySelector('form');
|
||||
|
||||
if (!textarea || !form) throw new Error('Elements not found');
|
||||
|
||||
await fireEvent.update(textarea, 'Test workflow prompt');
|
||||
await fireEvent.submit(form);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(openChat).toHaveBeenCalled();
|
||||
expect(sendChatMessage).toHaveBeenCalledWith({
|
||||
text: 'Test workflow prompt',
|
||||
source: 'canvas',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should save new workflow before opening chat', async () => {
|
||||
isNewWorkflow.value = true;
|
||||
const { container } = renderComponent();
|
||||
const textarea = container.querySelector('textarea');
|
||||
const form = container.querySelector('form');
|
||||
|
||||
if (!textarea || !form) throw new Error('Elements not found');
|
||||
|
||||
await fireEvent.update(textarea, 'Test prompt');
|
||||
await fireEvent.submit(form);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(saveCurrentWorkflow).toHaveBeenCalled();
|
||||
expect(openChat).toHaveBeenCalled();
|
||||
// Ensure save is called before chat opens
|
||||
expect(saveCurrentWorkflow.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
openChat.mock.invocationCallOrder[0],
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('suggestion pills', () => {
|
||||
it('should render all workflow suggestions', () => {
|
||||
const { container } = renderComponent();
|
||||
const pills = container.querySelectorAll('[role="group"] button');
|
||||
|
||||
expect(pills).toHaveLength(WORKFLOW_SUGGESTIONS.length);
|
||||
|
||||
WORKFLOW_SUGGESTIONS.forEach((suggestion, index) => {
|
||||
expect(pills[index]).toHaveTextContent(suggestion.summary);
|
||||
});
|
||||
});
|
||||
|
||||
it('should replace prompt when suggestion is clicked', async () => {
|
||||
const { container } = renderComponent();
|
||||
const firstPill = container.querySelector('[role="group"] button');
|
||||
const textarea = container.querySelector('textarea');
|
||||
|
||||
if (!firstPill || !textarea) throw new Error('Elements not found');
|
||||
|
||||
await fireEvent.click(firstPill);
|
||||
|
||||
expect(textarea).toHaveValue(WORKFLOW_SUGGESTIONS[0].prompt);
|
||||
});
|
||||
|
||||
it('should show confirmation dialog when user has edited prompt', async () => {
|
||||
confirmMock.mockResolvedValue(MODAL_CONFIRM);
|
||||
const { container } = renderComponent();
|
||||
const textarea = container.querySelector('textarea');
|
||||
const firstPill = container.querySelector('[role="group"] button');
|
||||
|
||||
if (!textarea || !firstPill) throw new Error('Elements not found');
|
||||
|
||||
// Type in textarea (triggers userEditedPrompt = true)
|
||||
await fireEvent.update(textarea, 'My custom prompt');
|
||||
await fireEvent.input(textarea);
|
||||
|
||||
// Click a suggestion
|
||||
await fireEvent.click(firstPill);
|
||||
|
||||
expect(confirmMock).toHaveBeenCalled();
|
||||
|
||||
// After confirmation, prompt should be replaced
|
||||
await waitFor(() => {
|
||||
expect(textarea).toHaveValue(WORKFLOW_SUGGESTIONS[0].prompt);
|
||||
});
|
||||
});
|
||||
|
||||
it('should not show confirmation when prompt is empty', async () => {
|
||||
const { container } = renderComponent();
|
||||
const firstPill = container.querySelector('[role="group"] button');
|
||||
const textarea = container.querySelector('textarea');
|
||||
|
||||
if (!firstPill || !textarea) throw new Error('Elements not found');
|
||||
|
||||
await fireEvent.click(firstPill);
|
||||
|
||||
expect(confirmMock).not.toHaveBeenCalled();
|
||||
expect(textarea).toHaveValue(WORKFLOW_SUGGESTIONS[0].prompt);
|
||||
});
|
||||
|
||||
it('should track telemetry when suggestion is clicked', async () => {
|
||||
const { container } = renderComponent();
|
||||
const firstPill = container.querySelector('[role="group"] button');
|
||||
|
||||
if (!firstPill) throw new Error('Pill not found');
|
||||
|
||||
await fireEvent.click(firstPill);
|
||||
|
||||
expect(telemetryTrack).toHaveBeenCalledWith('User clicked suggestion pill', {
|
||||
prompt: '',
|
||||
suggestion: WORKFLOW_SUGGESTIONS[0].id,
|
||||
});
|
||||
});
|
||||
|
||||
it('should not replace prompt if user cancels confirmation', async () => {
|
||||
confirmMock.mockResolvedValue('cancel'); // Not MODAL_CONFIRM
|
||||
const { container } = renderComponent();
|
||||
const textarea = container.querySelector('textarea');
|
||||
const firstPill = container.querySelector('[role="group"] button');
|
||||
|
||||
if (!textarea || !firstPill) throw new Error('Elements not found');
|
||||
|
||||
// Type in textarea
|
||||
await fireEvent.update(textarea, 'My custom prompt');
|
||||
await fireEvent.input(textarea);
|
||||
|
||||
const originalValue = textarea.value;
|
||||
|
||||
// Click suggestion
|
||||
await fireEvent.click(firstPill);
|
||||
|
||||
// Prompt should not be replaced
|
||||
expect(textarea).toHaveValue(originalValue);
|
||||
});
|
||||
});
|
||||
|
||||
describe('manual node creation', () => {
|
||||
it('should open node creator when "Add node manually" is clicked', async () => {
|
||||
const { container } = renderComponent();
|
||||
const addButton = container.querySelector('[aria-label="Add node manually"]');
|
||||
|
||||
if (!addButton) throw new Error('Add button not found');
|
||||
|
||||
await fireEvent.click(addButton);
|
||||
|
||||
expect(openNodeCreatorForTriggerNodes).toHaveBeenCalledWith(
|
||||
NODE_CREATOR_OPEN_SOURCES.TRIGGER_PLACEHOLDER_BUTTON,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('event propagation', () => {
|
||||
it.each(['click', 'dblclick', 'mousedown', 'scroll', 'wheel'])(
|
||||
'should stop propagation of %s event on prompt container',
|
||||
(eventType) => {
|
||||
const { container } = renderComponent();
|
||||
const promptContainer = container.querySelector('.promptContainer');
|
||||
|
||||
if (!promptContainer) throw new Error('Prompt container not found');
|
||||
|
||||
const event = new Event(eventType, { bubbles: true });
|
||||
const stopPropagationSpy = vi.spyOn(event, 'stopPropagation');
|
||||
|
||||
promptContainer.dispatchEvent(event);
|
||||
|
||||
expect(stopPropagationSpy).toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,52 +1,122 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue';
|
||||
import { useI18n } from '@n8n/i18n';
|
||||
import { useCanvasNode } from '@/composables/useCanvasNode';
|
||||
import { useBuilderStore } from '@/stores/builder.store';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useWorkflowSaving } from '@/composables/useWorkflowSaving';
|
||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||
import { useMessage } from '@/composables/useMessage';
|
||||
import { useTelemetry } from '@/composables/useTelemetry';
|
||||
import { useNodeCreatorStore } from '@/stores/nodeCreator.store';
|
||||
import { MODAL_CONFIRM, NODE_CREATOR_OPEN_SOURCES } from '@/constants';
|
||||
import { WORKFLOW_SUGGESTIONS } from '@/constants/workflowSuggestions';
|
||||
import type { WorkflowSuggestion } from '@/constants/workflowSuggestions';
|
||||
|
||||
const emit = defineEmits<{
|
||||
delete: [id: string];
|
||||
}>();
|
||||
// Composables
|
||||
const i18n = useI18n();
|
||||
const router = useRouter();
|
||||
const message = useMessage();
|
||||
const telemetry = useTelemetry();
|
||||
|
||||
const { id } = useCanvasNode();
|
||||
// Stores
|
||||
const nodeCreatorStore = useNodeCreatorStore();
|
||||
const builderStore = useBuilderStore();
|
||||
const workflowsStore = useWorkflowsStore();
|
||||
|
||||
// Services
|
||||
const workflowSaver = useWorkflowSaving({ router });
|
||||
const isPromptVisible = ref(true);
|
||||
|
||||
// Component state
|
||||
const prompt = ref('');
|
||||
const userEditedPrompt = ref(false);
|
||||
const isFocused = ref(false);
|
||||
|
||||
const prompt = ref('');
|
||||
// Computed properties
|
||||
const hasContent = computed(() => prompt.value.trim().length > 0);
|
||||
|
||||
// Static data
|
||||
const suggestions = ref(WORKFLOW_SUGGESTIONS);
|
||||
|
||||
/**
|
||||
* Handles form submission to build a workflow from the prompt
|
||||
*/
|
||||
async function onSubmit() {
|
||||
if (!hasContent.value || builderStore.streaming) return;
|
||||
|
||||
const isNewWorkflow = workflowsStore.isNewWorkflow;
|
||||
|
||||
// Save the workflow to get workflow ID which is used for session
|
||||
if (isNewWorkflow) {
|
||||
await workflowSaver.saveCurrentWorkflow();
|
||||
}
|
||||
|
||||
// Here we need to await for chat to open and session to be loaded
|
||||
await builderStore.openChat();
|
||||
emit('delete', id.value);
|
||||
|
||||
builderStore.sendChatMessage({ text: prompt.value, source: 'canvas' });
|
||||
isPromptVisible.value = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles clicking on a suggestion pill
|
||||
* @param suggestion - The workflow suggestion that was clicked
|
||||
*/
|
||||
async function onSuggestionClick(suggestion: WorkflowSuggestion) {
|
||||
// Track telemetry
|
||||
telemetry.track('User clicked suggestion pill', {
|
||||
prompt: prompt.value,
|
||||
suggestion: suggestion.id,
|
||||
});
|
||||
|
||||
// Show confirmation if there's content AND the user has edited the prompt
|
||||
if (hasContent.value && userEditedPrompt.value) {
|
||||
const confirmed = await message.confirm(
|
||||
i18n.baseText('aiAssistant.builder.canvasPrompt.confirmMessage'),
|
||||
i18n.baseText('aiAssistant.builder.canvasPrompt.confirmTitle'),
|
||||
{
|
||||
confirmButtonText: i18n.baseText('aiAssistant.builder.canvasPrompt.confirmButton'),
|
||||
cancelButtonText: i18n.baseText('aiAssistant.builder.canvasPrompt.cancelButton'),
|
||||
},
|
||||
);
|
||||
|
||||
if (confirmed !== MODAL_CONFIRM) return;
|
||||
}
|
||||
|
||||
// Set the prompt without submitting
|
||||
prompt.value = suggestion.prompt;
|
||||
|
||||
// Reset the edited flag
|
||||
userEditedPrompt.value = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the node creator for adding trigger nodes manually
|
||||
*/
|
||||
function onAddNodeClick() {
|
||||
nodeCreatorStore.openNodeCreatorForTriggerNodes(
|
||||
NODE_CREATOR_OPEN_SOURCES.TRIGGER_PLACEHOLDER_BUTTON,
|
||||
);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="isPromptVisible" :class="$style.container" data-test-id="canvas-ai-prompt">
|
||||
<div :class="[$style.promptContainer, { [$style.focused]: isFocused }]">
|
||||
<article :class="$style.container" data-test-id="canvas-ai-prompt">
|
||||
<header>
|
||||
<h2 :class="$style.title">{{ i18n.baseText('aiAssistant.builder.canvasPrompt.title') }}</h2>
|
||||
</header>
|
||||
|
||||
<!-- Prompt input section -->
|
||||
<section
|
||||
:class="[$style.promptContainer, { [$style.focused]: isFocused }]"
|
||||
@click.stop
|
||||
@dblclick.stop
|
||||
@mousedown.stop
|
||||
@scroll.stop
|
||||
@wheel.stop
|
||||
>
|
||||
<form :class="$style.form" @submit.prevent="onSubmit">
|
||||
<n8n-input
|
||||
v-model="prompt"
|
||||
:class="$style.form_textarea"
|
||||
name="aiBuilderPrompt"
|
||||
:class="$style.formTextarea"
|
||||
type="textarea"
|
||||
:disabled="builderStore.streaming"
|
||||
:placeholder="i18n.baseText('aiAssistant.builder.placeholder')"
|
||||
@@ -55,46 +125,90 @@ async function onSubmit() {
|
||||
@focus="isFocused = true"
|
||||
@blur="isFocused = false"
|
||||
@keydown.meta.enter.stop="onSubmit"
|
||||
@input="userEditedPrompt = true"
|
||||
/>
|
||||
<div :class="$style.form_footer">
|
||||
<footer :class="$style.formFooter">
|
||||
<n8n-button
|
||||
native-type="submit"
|
||||
:disabled="!hasContent || builderStore.streaming"
|
||||
@keydown.enter="onSubmit"
|
||||
>{{ i18n.baseText('aiAssistant.builder.buildWorkflow') }}</n8n-button
|
||||
>
|
||||
</div>
|
||||
{{ i18n.baseText('aiAssistant.builder.canvasPrompt.buildWorkflow') }}
|
||||
</n8n-button>
|
||||
</footer>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<!-- Suggestion pills section -->
|
||||
<section :class="$style.pillsContainer" role="group" aria-label="Workflow suggestions">
|
||||
<button
|
||||
v-for="suggestion in suggestions"
|
||||
:key="suggestion.id"
|
||||
:class="$style.suggestionPill"
|
||||
type="button"
|
||||
@click="onSuggestionClick(suggestion)"
|
||||
>
|
||||
{{ suggestion.summary }}
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<!-- Divider -->
|
||||
<div :class="$style.orDivider" role="separator">
|
||||
<span :class="$style.orText">{{ i18n.baseText('generic.or') }}</span>
|
||||
</div>
|
||||
<div :class="$style.or">
|
||||
<p :class="$style.or_text">or</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Manual node creation section -->
|
||||
<section :class="$style.startManually" @click.stop="onAddNodeClick">
|
||||
<button :class="$style.addButton" type="button" aria-label="Add node manually">
|
||||
<n8n-icon icon="plus" :size="40" />
|
||||
</button>
|
||||
<div :class="$style.startManuallyLabel">
|
||||
<strong :class="$style.startManuallyText">
|
||||
{{ i18n.baseText('aiAssistant.builder.canvasPrompt.startManually.title') }}
|
||||
</strong>
|
||||
<span :class="$style.startManuallySubtitle">
|
||||
{{ i18n.baseText('aiAssistant.builder.canvasPrompt.startManually.subTitle') }}
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
</article>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
/* Layout */
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--spacing-m);
|
||||
max-width: 710px;
|
||||
}
|
||||
|
||||
.promptContainer {
|
||||
--width: 620px;
|
||||
--height: 150px;
|
||||
/* Header */
|
||||
.title {
|
||||
font-size: var(--font-size-s);
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--color-text-dark);
|
||||
}
|
||||
|
||||
/* Prompt Input Section */
|
||||
.promptContainer {
|
||||
display: flex;
|
||||
height: 128px;
|
||||
padding: var(--spacing-xs);
|
||||
padding-left: var(--spacing-s);
|
||||
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;
|
||||
justify-content: flex-end;
|
||||
align-items: flex-end;
|
||||
gap: var(--spacing-s);
|
||||
align-self: stretch;
|
||||
border-radius: var(--border-radius-large);
|
||||
border: 1px solid var(--color-foreground-xdark);
|
||||
background: var(--color-background-xlight);
|
||||
transition: border-color 0.2s ease;
|
||||
|
||||
&.focused {
|
||||
border: 1px solid var(--color-primary);
|
||||
border-color: var(--prim-color-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,9 +217,11 @@ async function onSubmit() {
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
width: 100%;
|
||||
gap: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.form_textarea {
|
||||
.formTextarea {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
@@ -120,26 +236,136 @@ async function onSubmit() {
|
||||
background: transparent;
|
||||
resize: none;
|
||||
font-family: var(--font-family);
|
||||
padding: 0;
|
||||
|
||||
/* Custom scrollbar styles */
|
||||
@supports not (selector(::-webkit-scrollbar)) {
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
@supports selector(::-webkit-scrollbar) {
|
||||
&::-webkit-scrollbar {
|
||||
width: var(--spacing-2xs);
|
||||
}
|
||||
&::-webkit-scrollbar-thumb {
|
||||
border-radius: var(--spacing-xs);
|
||||
background: var(--color-foreground-dark);
|
||||
border: var(--spacing-5xs) solid white;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.form_footer {
|
||||
.formFooter {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding: var(--spacing-2xs);
|
||||
}
|
||||
|
||||
.or {
|
||||
/* Suggestion Pills Section */
|
||||
.pillsContainer {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: flex-start;
|
||||
gap: var(--spacing-2xs);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.suggestionPill {
|
||||
display: flex;
|
||||
padding: var(--spacing-2xs) var(--spacing-xs);
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: var(--spacing-2xs);
|
||||
border-radius: 56px;
|
||||
border: var(--border-base);
|
||||
background: var(--color-background-xlight);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
font-size: var(--font-size-2xs);
|
||||
color: var(--color-text-dark);
|
||||
font-weight: var(--font-weight-regular);
|
||||
|
||||
&:hover {
|
||||
color: var(--color-primary);
|
||||
border-color: var(--color-primary);
|
||||
background: var(--color-background-xlight);
|
||||
}
|
||||
}
|
||||
|
||||
/* Divider Section */
|
||||
.orDivider {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 60px;
|
||||
height: 100px;
|
||||
cursor: auto;
|
||||
gap: var(--spacing-s);
|
||||
align-self: stretch;
|
||||
position: relative;
|
||||
|
||||
&::before,
|
||||
&::after {
|
||||
content: '';
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background-color: var(--color-foreground-base);
|
||||
}
|
||||
}
|
||||
|
||||
.or_text {
|
||||
.orText {
|
||||
font-size: var(--font-size-m);
|
||||
color: var(--color-text-base);
|
||||
padding: 0 var(--spacing-s);
|
||||
}
|
||||
|
||||
/* Manual Node Creation Section */
|
||||
.startManually {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-xs);
|
||||
cursor: pointer;
|
||||
|
||||
.addButton {
|
||||
background: var(--color-foreground-xlight);
|
||||
border: 1px dashed var(--color-foreground-xdark);
|
||||
border-radius: var(--border-radius-base);
|
||||
min-width: 80px;
|
||||
min-height: 80px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
|
||||
svg {
|
||||
width: 31px !important;
|
||||
height: 40px;
|
||||
path {
|
||||
color: var(--color-foreground-xdark);
|
||||
fill: var(--color-foreground-xdark);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&:hover .addButton {
|
||||
border-color: var(--color-button-secondary-hover-active-focus-border);
|
||||
|
||||
svg path {
|
||||
color: var(--color-primary);
|
||||
fill: var(--color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.startManuallyLabel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
font-size: var(--font-size-s);
|
||||
line-height: var(--font-line-height-xloose);
|
||||
|
||||
.startManuallyText {
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--color-text-dark);
|
||||
}
|
||||
|
||||
.startManuallySubtitle {
|
||||
font-weight: var(--font-weight-regular);
|
||||
color: var(--color-text-base);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`CanvasNodeAIPrompt > should render component correctly 1`] = `
|
||||
"<article class="container" data-test-id="canvas-ai-prompt">
|
||||
<header>
|
||||
<h2 class="title">What would you like to automate?</h2>
|
||||
</header><!-- Prompt input section -->
|
||||
<section class="promptContainer">
|
||||
<form class="form">
|
||||
<div class="el-textarea el-input--large n8n-input formTextarea formTextarea">
|
||||
<!-- input -->
|
||||
<!-- textarea --><textarea class="el-textarea__inner" name="aiBuilderPrompt" rows="15" title="" read-only="false" tabindex="0" autocomplete="off" placeholder="Ask n8n to build..."></textarea>
|
||||
<!--v-if-->
|
||||
</div>
|
||||
<footer class="formFooter"><button class="button button primary medium disabled" disabled="" aria-disabled="true" aria-live="polite" type="submit">
|
||||
<!--v-if-->Create workflow
|
||||
</button></footer>
|
||||
</form>
|
||||
</section><!-- Suggestion pills section -->
|
||||
<section class="pillsContainer" role="group" aria-label="Workflow suggestions"><button class="suggestionPill" type="button">Invoice processing pipeline</button><button class="suggestionPill" type="button">Daily AI news digest</button><button class="suggestionPill" type="button">RAG knowledge assistant</button><button class="suggestionPill" type="button">Summarize emails with AI</button><button class="suggestionPill" type="button">YouTube video chapters</button><button class="suggestionPill" type="button">Pizza delivery chatbot</button><button class="suggestionPill" type="button">Lead qualification and call scheduling</button><button class="suggestionPill" type="button">Multi-agent research workflow</button></section><!-- Divider -->
|
||||
<div class="orDivider" role="separator"><span class="orText">or</span></div><!-- Manual node creation section -->
|
||||
<section class="startManually"><button class="addButton" type="button" aria-label="Add node manually"><svg viewBox="0 0 24 24" width="40px" height="40px" class="n8n-icon" aria-hidden="true" focusable="false" role="img" data-icon="plus">
|
||||
<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h14m-7-7v14"></path>
|
||||
</svg></button>
|
||||
<div class="startManuallyLabel"><strong class="startManuallyText">Start manually</strong><span class="startManuallySubtitle">Add the first node</span></div>
|
||||
</section>
|
||||
</article>"
|
||||
`;
|
||||
@@ -14,7 +14,8 @@ exports[`useCanvasOperations > copyNodes > should copy nodes 1`] = `
|
||||
40,
|
||||
40
|
||||
],
|
||||
"typeVersion": 1
|
||||
"typeVersion": 1,
|
||||
"draggable": true
|
||||
},
|
||||
{
|
||||
"parameters": {},
|
||||
@@ -25,7 +26,8 @@ exports[`useCanvasOperations > copyNodes > should copy nodes 1`] = `
|
||||
40,
|
||||
40
|
||||
],
|
||||
"typeVersion": 1
|
||||
"typeVersion": 1,
|
||||
"draggable": true
|
||||
}
|
||||
],
|
||||
"connections": {},
|
||||
@@ -52,7 +54,8 @@ exports[`useCanvasOperations > cutNodes > should copy and delete nodes 1`] = `
|
||||
40,
|
||||
40
|
||||
],
|
||||
"typeVersion": 1
|
||||
"typeVersion": 1,
|
||||
"draggable": true
|
||||
},
|
||||
{
|
||||
"parameters": {},
|
||||
@@ -63,7 +66,8 @@ exports[`useCanvasOperations > cutNodes > should copy and delete nodes 1`] = `
|
||||
40,
|
||||
40
|
||||
],
|
||||
"typeVersion": 1
|
||||
"typeVersion": 1,
|
||||
"draggable": true
|
||||
}
|
||||
],
|
||||
"connections": {},
|
||||
|
||||
@@ -101,6 +101,7 @@ describe('useCanvasMapping', () => {
|
||||
label: manualTriggerNode.name,
|
||||
type: 'canvas-node',
|
||||
position: expect.anything(),
|
||||
draggable: true,
|
||||
data: {
|
||||
id: manualTriggerNode.id,
|
||||
name: manualTriggerNode.name,
|
||||
|
||||
@@ -624,6 +624,7 @@ export function useCanvasMapping({
|
||||
position: { x: node.position[0], y: node.position[1] },
|
||||
data,
|
||||
...additionalNodePropertiesById.value[node.id],
|
||||
draggable: node.draggable ?? true,
|
||||
};
|
||||
}),
|
||||
];
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
export interface WorkflowSuggestion {
|
||||
id: string;
|
||||
summary: string; // Short text shown on the pill
|
||||
prompt: string; // Full prompt
|
||||
}
|
||||
|
||||
export const WORKFLOW_SUGGESTIONS: WorkflowSuggestion[] = [
|
||||
{
|
||||
id: 'invoice-pipeline',
|
||||
summary: 'Invoice processing pipeline',
|
||||
prompt:
|
||||
'Create an invoice parsing workflow using n8n forms. Extract key information (vendor, date, amount, line items) using AI, validate the data, and store structured information in Airtable. Generate a weekly spending report every Sunday at 6 PM using AI analysis and send via email.',
|
||||
},
|
||||
{
|
||||
id: 'ai-news-digest',
|
||||
summary: 'Daily AI news digest',
|
||||
prompt:
|
||||
'Create a workflow that fetches the latest AI news every morning at 8 AM. It should aggregate news from multiple sources, use LLM to summarize the top 5 stories, generate a relevant image using AI, and send everything as a structured Telegram message with article links. I should be able to chat about the news with the LLM so at least 40 last messages should be stored.',
|
||||
},
|
||||
{
|
||||
id: 'rag-assistant',
|
||||
summary: 'RAG knowledge assistant',
|
||||
prompt:
|
||||
'Build a pipeline that accepts PDF, CSV, or JSON files through an n8n form. Chunk documents into 1000-token segments, generate embeddings, and store in a vector database. Use the filename as the document key and add metadata including upload date and file type. Include a chatbot that can answer questions based on a knowledge base.',
|
||||
},
|
||||
{
|
||||
id: 'email-summary',
|
||||
summary: 'Summarize emails with AI',
|
||||
prompt:
|
||||
'Build a workflow that retrieves the last 50 emails from multiple email accounts. Merge all emails, perform AI analysis to identify action items, priorities, and sentiment. Generate a brief summary and send to Slack with categorized insights and recommended actions.',
|
||||
},
|
||||
{
|
||||
id: 'youtube-auto-chapters',
|
||||
summary: 'YouTube video chapters',
|
||||
prompt:
|
||||
"I want to build an n8n workflow that automatically creates YouTube chapter timestamps by analyzing the video captions. When I trigger it manually, it should take a video ID as input, fetch the existing video metadata and captions from YouTube, use an AI language model like Google Gemini to parse the transcript into chapters with timestamps, and then update the video's description with these chapters appended. The goal is to save time and improve SEO by automating the whole process.",
|
||||
},
|
||||
{
|
||||
id: 'pizza-delivery',
|
||||
summary: 'Pizza delivery chatbot',
|
||||
prompt:
|
||||
"I need an n8n workflow that creates a chatbot for my pizza delivery service. The bot should be able to answer customer questions about our pizza menu, take their orders accurately by capturing pizza type, quantity, and customer details, and also provide real-time updates when customers ask about their order status. It should use OpenAI's gpt-4.1-mini to handle conversations and integrate with HTTP APIs to get product info and manage orders. The workflow must maintain conversation context so the chatbot feels natural and can process multiple user queries sequentially.",
|
||||
},
|
||||
{
|
||||
id: 'lead-qualification',
|
||||
summary: 'Lead qualification and call scheduling',
|
||||
prompt:
|
||||
'Create a form with fields for email, company, and role. Build an automation that processes form submissions, enrich with company data from their website, uses AI to qualify the lead, sends data to Google Sheets. For high-score leads it should also schedule a 15-min call in a free slot in my calendar and send a confirmation email to both me and the lead.',
|
||||
},
|
||||
{
|
||||
id: 'multi-agent-research',
|
||||
summary: 'Multi-agent research workflow',
|
||||
prompt:
|
||||
'Create a multi-agent AI workflow where different AI agents collaborate to research a topic, fact-check information, and compile comprehensive reports.',
|
||||
},
|
||||
];
|
||||
@@ -1836,29 +1836,34 @@ 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: {},
|
||||
});
|
||||
if (isReadOnlyOrLoading) {
|
||||
fallbackNodes.value = [];
|
||||
return;
|
||||
}
|
||||
|
||||
fallbackNodes.value = isReadOnlyOrLoading ? [] : defaultFallbackNodes;
|
||||
const addNodesItem: INodeUi = {
|
||||
id: CanvasNodeRenderType.AddNodes,
|
||||
name: CanvasNodeRenderType.AddNodes,
|
||||
type: CanvasNodeRenderType.AddNodes,
|
||||
typeVersion: 1,
|
||||
position: [0, 0],
|
||||
parameters: {},
|
||||
};
|
||||
|
||||
const aiPromptItem: INodeUi = {
|
||||
id: CanvasNodeRenderType.AIPrompt,
|
||||
name: CanvasNodeRenderType.AIPrompt,
|
||||
type: CanvasNodeRenderType.AIPrompt,
|
||||
typeVersion: 1,
|
||||
position: [-690, -15],
|
||||
parameters: {},
|
||||
draggable: false,
|
||||
};
|
||||
|
||||
fallbackNodes.value =
|
||||
builderStore.isAIBuilderEnabled && builderStore.isAssistantEnabled
|
||||
? [aiPromptItem]
|
||||
: [addNodesItem];
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user