feat: Add planning step to AI workflow builder (no-changelog) (#18737)

Co-authored-by: Eugene Molodkin <eugene@n8n.io>
This commit is contained in:
oleg
2025-09-01 16:28:19 +02:00
committed by GitHub
parent caeaa679c6
commit 94f0048f02
31 changed files with 2977 additions and 1281 deletions

View File

@@ -26,6 +26,7 @@ import { mockedStore } from '@/__tests__/utils';
import { STORES } from '@n8n/stores';
import { useWorkflowsStore } from '@/stores/workflows.store';
import type { INodeUi } from '@/Interface';
import { PLAN_APPROVAL_MESSAGE } from '@/constants';
vi.mock('@/event-bus', () => ({
nodeViewEventBus: {
@@ -976,4 +977,382 @@ describe('AskAssistantBuild', () => {
expect(builderStore.initialGeneration).toBe(false);
});
});
describe('NodesPlan message handling', () => {
it('should render plan messages with appropriate controls', async () => {
const nodesPlanMessage = {
id: faker.string.uuid(),
role: 'assistant' as const,
type: 'custom' as const,
customType: 'nodesPlan',
message: 'Here is my plan:',
data: [
{
nodeType: 'n8n-nodes-base.httpRequest',
nodeName: 'HTTP Request',
reasoning: 'To fetch data from an API',
},
],
};
const regularMessage = {
id: faker.string.uuid(),
role: 'assistant' as const,
type: 'text' as const,
content: 'Regular message',
};
// First test with regular message - should not show plan controls
builderStore.$patch({
chatMessages: [regularMessage],
});
const { queryByText } = renderComponent();
await flushPromises();
// Regular message should not have plan controls
expect(queryByText('aiAssistant.builder.plan.approve')).not.toBeInTheDocument();
// Now test with plan message
builderStore.$patch({
chatMessages: [nodesPlanMessage],
});
await flushPromises();
// Plan message should have controls
expect(queryByText('aiAssistant.builder.plan.approve')).toBeInTheDocument();
});
it('should handle plan approval correctly', async () => {
// Setup: empty workflow so initialGeneration is true
workflowsStore.$patch({ workflow: { nodes: [], connections: {} } });
const nodesPlanMessage = {
id: faker.string.uuid(),
role: 'assistant' as const,
type: 'custom' as const,
customType: 'nodesPlan',
message: 'Here is my plan:',
data: [
{
nodeType: 'n8n-nodes-base.httpRequest',
nodeName: 'HTTP Request',
reasoning: 'To fetch data from an API',
},
],
};
builderStore.$patch({
chatMessages: [nodesPlanMessage],
});
const { getByText } = renderComponent();
await flushPromises();
// Find and click the approve button
const approveButton = getByText('aiAssistant.builder.plan.approve');
await fireEvent.click(approveButton);
await flushPromises();
// Verify that sendChatMessage was called with approval message
expect(builderStore.sendChatMessage).toHaveBeenCalledWith({
text: PLAN_APPROVAL_MESSAGE,
initialGeneration: true,
});
});
it('should handle plan rejection correctly', async () => {
const nodesPlanMessage = {
id: faker.string.uuid(),
role: 'assistant' as const,
type: 'custom' as const,
customType: 'nodesPlan',
message: 'Here is my plan:',
data: [
{
nodeType: 'n8n-nodes-base.httpRequest',
nodeName: 'HTTP Request',
reasoning: 'To fetch data from an API',
},
],
};
builderStore.$patch({
chatMessages: [nodesPlanMessage],
});
const { getByText, queryByText } = renderComponent();
await flushPromises();
// Find and click the reject button
const rejectButton = getByText('aiAssistant.builder.plan.reject');
await fireEvent.click(rejectButton);
await flushPromises();
// Verify plan controls are hidden after rejection
expect(queryByText('aiAssistant.builder.plan.approve')).not.toBeInTheDocument();
expect(queryByText('aiAssistant.builder.plan.reject')).not.toBeInTheDocument();
});
it('should show plan controls only for the last plan message', async () => {
const planMessage1 = {
id: 'plan1',
role: 'assistant' as const,
type: 'custom' as const,
customType: 'nodesPlan',
message: 'First plan:',
data: [
{
nodeType: 'n8n-nodes-base.httpRequest',
nodeName: 'HTTP Request',
reasoning: 'First plan',
},
],
};
const textMessage = {
id: 'text1',
role: 'assistant' as const,
type: 'text' as const,
content: 'Some text',
};
const planMessage2 = {
id: 'plan2',
role: 'assistant' as const,
type: 'custom' as const,
customType: 'nodesPlan',
message: 'Second plan:',
data: [
{
nodeType: 'n8n-nodes-base.emailSend',
nodeName: 'Send Email',
reasoning: 'Second plan',
},
],
};
// First set just the first plan message
builderStore.$patch({
chatMessages: [planMessage1],
});
const { queryByText } = renderComponent();
await flushPromises();
// First plan should show controls when it's alone
expect(queryByText('aiAssistant.builder.plan.approve')).toBeInTheDocument();
// Now add more messages
builderStore.$patch({
chatMessages: [planMessage1, textMessage, planMessage2],
});
await flushPromises();
// Now only the last plan message should have visible controls
// We check this by seeing that approve button is still there (from plan2)
expect(queryByText('aiAssistant.builder.plan.approve')).toBeInTheDocument();
});
});
describe('Plan status management in workflow messages watcher', () => {
it('should disable chat when plan is pending and enable after action', async () => {
// Add a nodes plan message as the last message
const nodesPlanMessage = {
id: faker.string.uuid(),
role: 'assistant' as const,
type: 'custom' as const,
customType: 'nodesPlan',
message: 'Plan message',
data: [
{
nodeType: 'n8n-nodes-base.httpRequest',
nodeName: 'HTTP Request',
reasoning: 'To fetch data',
},
],
};
builderStore.$patch({
chatMessages: [nodesPlanMessage],
});
// Also need to mock workflowMessages getter
builderStore.workflowMessages = [];
const { getByText, queryByRole } = renderComponent();
await flushPromises();
// Trigger the watcher by updating workflowMessages
builderStore.workflowMessages = [
{
id: faker.string.uuid(),
role: 'assistant' as const,
type: 'workflow-updated' as const,
codeSnippet: '{}',
},
];
await flushPromises();
// Chat should be disabled while plan is pending
const chatInput = queryByRole('textbox');
expect(chatInput).toHaveAttribute('disabled');
// Approve the plan
const approveButton = getByText('aiAssistant.builder.plan.approve');
await fireEvent.click(approveButton);
await flushPromises();
// Chat should be enabled after approval
const chatInputAfter = queryByRole('textbox');
expect(chatInputAfter).not.toHaveAttribute('disabled');
});
it('should not disable chat when last message is not a nodes plan', async () => {
// Add a regular text message as the last message
const textMessage = {
id: faker.string.uuid(),
role: 'assistant' as const,
type: 'text' as const,
content: 'Regular message',
};
builderStore.$patch({
chatMessages: [textMessage],
});
// Mock workflowMessages getter
builderStore.workflowMessages = [];
const { queryByRole } = renderComponent();
await flushPromises();
// Trigger the watcher
builderStore.workflowMessages = [
{
id: faker.string.uuid(),
role: 'assistant' as const,
type: 'workflow-updated' as const,
codeSnippet: '{}',
},
];
await flushPromises();
// Chat should remain enabled
const chatInput = queryByRole('textbox');
expect(chatInput).not.toHaveAttribute('disabled');
});
});
describe('Disabled state when plan is pending', () => {
it('should disable chat input when plan status is pending', async () => {
const nodesPlanMessage = {
id: faker.string.uuid(),
role: 'assistant' as const,
type: 'custom' as const,
customType: 'nodesPlan',
message: 'Plan message',
data: [
{
nodeType: 'n8n-nodes-base.httpRequest',
nodeName: 'HTTP Request',
reasoning: 'To fetch data',
},
],
};
builderStore.$patch({
chatMessages: [nodesPlanMessage],
});
// Mock workflowMessages getter
builderStore.workflowMessages = [];
const { queryByRole } = renderComponent();
await flushPromises();
// Trigger workflow message update to set planStatus to pending
builderStore.workflowMessages = [
{
id: faker.string.uuid(),
role: 'assistant' as const,
type: 'workflow-updated' as const,
codeSnippet: '{}',
},
];
await flushPromises();
const chatInput = queryByRole('textbox');
expect(chatInput).toHaveAttribute('disabled');
});
it('should enable chat input after plan approval', async () => {
const nodesPlanMessage = {
id: faker.string.uuid(),
role: 'assistant' as const,
type: 'custom' as const,
customType: 'nodesPlan',
message: 'Plan message',
data: [
{
nodeType: 'n8n-nodes-base.httpRequest',
nodeName: 'HTTP Request',
reasoning: 'To fetch data',
},
],
};
builderStore.$patch({
chatMessages: [nodesPlanMessage],
});
const { queryByRole, getByText } = renderComponent();
await flushPromises();
// Click approve button
const approveButton = getByText('aiAssistant.builder.plan.approve');
await fireEvent.click(approveButton);
await flushPromises();
// Chat should be enabled after approval
const chatInput = queryByRole('textbox');
expect(chatInput).not.toHaveAttribute('disabled');
});
it('should enable chat input after plan rejection', async () => {
const nodesPlanMessage = {
id: faker.string.uuid(),
role: 'assistant' as const,
type: 'custom' as const,
customType: 'nodesPlan',
message: 'Plan message',
data: [
{
nodeType: 'n8n-nodes-base.httpRequest',
nodeName: 'HTTP Request',
reasoning: 'To fetch data',
},
],
};
builderStore.$patch({
chatMessages: [nodesPlanMessage],
});
const { queryByRole, getByText } = renderComponent();
await flushPromises();
// Click reject button
const rejectButton = getByText('aiAssistant.builder.plan.reject');
await fireEvent.click(rejectButton);
await flushPromises();
// Chat should be enabled after rejection
const chatInput = queryByRole('textbox');
expect(chatInput).not.toHaveAttribute('disabled');
});
});
});

View File

@@ -1,16 +1,19 @@
<script lang="ts" setup>
import { useBuilderStore } from '@/stores/builder.store';
import { useUsersStore } from '@/stores/users.store';
import { computed, watch, ref } from 'vue';
import { computed, watch, ref, nextTick } from 'vue';
import AskAssistantChat from '@n8n/design-system/components/AskAssistantChat/AskAssistantChat.vue';
import { useTelemetry } from '@/composables/useTelemetry';
import { useI18n } from '@n8n/i18n';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { useRoute, useRouter } from 'vue-router';
import { useWorkflowSaving } from '@/composables/useWorkflowSaving';
import type { RatingFeedback } from '@n8n/design-system/types/assistant';
import type { ChatUI, RatingFeedback } from '@n8n/design-system/types/assistant';
import { isWorkflowUpdatedMessage } from '@n8n/design-system/types/assistant';
import { nodeViewEventBus } from '@/event-bus';
import type { NodesPlanMessageType } from './NodesPlanMessage.vue';
import NodesPlanMessage from './NodesPlanMessage.vue';
import { PLAN_APPROVAL_MESSAGE } from '@/constants';
const emit = defineEmits<{
close: [];
@@ -28,6 +31,8 @@ const workflowSaver = useWorkflowSaving({ router });
// Track processed workflow updates
const processedWorkflowUpdates = ref(new Set<string>());
const trackedTools = ref(new Set<string>());
const planStatus = ref<'pending' | 'approved' | 'rejected'>();
const assistantChatRef = ref<InstanceType<typeof AskAssistantChat> | null>(null);
const user = computed(() => ({
firstName: usersStore.currentUser?.firstName ?? '',
@@ -51,6 +56,63 @@ async function onUserMessage(content: string) {
builderStore.sendChatMessage({ text: content, initialGeneration: isInitialGeneration });
}
function onNewWorkflow() {
builderStore.resetBuilderChat();
processedWorkflowUpdates.value.clear();
trackedTools.value.clear();
}
function onFeedback(feedback: RatingFeedback) {
if (feedback.rating) {
telemetry.track('User rated workflow generation', {
helpful: feedback.rating === 'up',
workflow_id: workflowsStore.workflowId,
session_id: builderStore.trackingSessionId,
});
}
if (feedback.feedback) {
telemetry.track('User submitted workflow generation feedback', {
feedback: feedback.feedback,
workflow_id: workflowsStore.workflowId,
session_id: builderStore.trackingSessionId,
});
}
}
function isNodesPlanMessage(message: ChatUI.AssistantMessage): message is NodesPlanMessageType {
return (
message.type === 'custom' && message.customType === 'nodesPlan' && Array.isArray(message.data)
);
}
function onApprovePlan() {
planStatus.value = 'approved';
telemetry.track('User clicked Approve plan', {
session_id: builderStore.trackingSessionId,
});
void onUserMessage(PLAN_APPROVAL_MESSAGE);
}
function onRequestChanges() {
planStatus.value = 'rejected';
telemetry.track('User clicked Request changes', {
session_id: builderStore.trackingSessionId,
});
// Focus the input after rejecting the plan
void nextTick(() => {
assistantChatRef.value?.focusInput();
});
}
function shouldShowPlanControls(message: NodesPlanMessageType) {
const planMessageIndex = builderStore.chatMessages.findIndex((msg) => msg.id === message.id);
return planMessageIndex === builderStore.chatMessages.length - 1;
}
// Watch for workflow updates and apply them
watch(
() => builderStore.workflowMessages,
@@ -95,6 +157,12 @@ watch(
}
}
});
// Check if last message is a plan message and if so, whether to show controls
const lastMessage = builderStore.chatMessages[builderStore.chatMessages.length - 1];
if (lastMessage && isNodesPlanMessage(lastMessage)) {
planStatus.value = 'pending';
}
},
{ deep: true },
);
@@ -126,29 +194,6 @@ watch(
},
);
function onNewWorkflow() {
builderStore.resetBuilderChat();
processedWorkflowUpdates.value.clear();
trackedTools.value.clear();
}
function onFeedback(feedback: RatingFeedback) {
if (feedback.rating) {
telemetry.track('User rated workflow generation', {
helpful: feedback.rating === 'up',
workflow_id: workflowsStore.workflowId,
session_id: builderStore.trackingSessionId,
});
}
if (feedback.feedback) {
telemetry.track('User submitted workflow generation feedback', {
feedback: feedback.feedback,
workflow_id: workflowsStore.workflowId,
session_id: builderStore.trackingSessionId,
});
}
}
// Reset on route change
watch(currentRoute, () => {
onNewWorkflow();
@@ -158,9 +203,11 @@ watch(currentRoute, () => {
<template>
<div data-test-id="ask-assistant-chat" tabindex="0" :class="$style.container" @keydown.stop>
<AskAssistantChat
ref="assistantChatRef"
:user="user"
:messages="builderStore.chatMessages"
:streaming="builderStore.streaming"
:disabled="planStatus === 'pending'"
:loading-message="loadingMessage"
:mode="i18n.baseText('aiAssistant.builder.mode')"
:title="'n8n AI'"
@@ -176,6 +223,16 @@ watch(currentRoute, () => {
<template #header>
<slot name="header" />
</template>
<template #custom-message="{ message, ...props }">
<NodesPlanMessage
v-if="message.customType === 'nodesPlan' && isNodesPlanMessage(message)"
:message="message"
:show-controls="shouldShowPlanControls(message)"
v-bind="props"
@approve-plan="onApprovePlan"
@request-changes="onRequestChanges"
/>
</template>
<template #placeholder>
<n8n-text :class="$style.topText">{{
i18n.baseText('aiAssistant.builder.placeholder')

View File

@@ -0,0 +1,134 @@
<!-- eslint-disable import-x/extensions -->
<script setup lang="ts">
import NodeIcon from '@/components/NodeIcon.vue';
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
import type { ChatUI, RatingFeedback } from '@n8n/design-system';
import BaseMessage from '@n8n/design-system/components/AskAssistantChat/messages/BaseMessage.vue';
import { useI18n } from '@n8n/i18n';
import { ref } from 'vue';
interface NodesPlan {
nodeType: string;
nodeName: string;
reasoning: string;
}
export type NodesPlanMessageType = ChatUI.CustomMessage & {
data: NodesPlan[];
};
interface Props {
message: NodesPlanMessageType;
showControls?: boolean;
isFirstOfRole: boolean;
user?: {
firstName: string;
lastName: string;
};
streaming?: boolean;
isLastMessage?: boolean;
}
const props = defineProps<Props>();
const nodeTypesStore = useNodeTypesStore();
const i18n = useI18n();
const showControlsLocal = ref(props.showControls ?? true);
const changesRequested = ref(false);
const emit = defineEmits<{
approvePlan: [];
requestChanges: [];
feedback: [RatingFeedback];
}>();
function onApprovePlan() {
showControlsLocal.value = false;
emit('approvePlan');
}
function onRequestChanges() {
showControlsLocal.value = false;
changesRequested.value = true;
emit('requestChanges');
}
</script>
<template>
<BaseMessage
:class="$style.message"
:message="message"
:is-first-of-role="true"
:user="user"
@feedback="(feedback) => emit('feedback', feedback)"
>
{{ message.message }}
<ol :class="$style.nodes">
<li v-for="(node, index) in message.data" :key="index" :class="$style.node">
<n8n-tooltip placement="left" :show-after="300">
<template #content>
{{ node.reasoning }}
</template>
<div :class="$style.node">
<NodeIcon
:class="$style.nodeIcon"
:node-type="nodeTypesStore.getNodeType(node.nodeType)"
:node-name="node.nodeName"
:show-tooltip="false"
:size="12"
/>
<span>{{ node.nodeName }}</span>
</div>
</n8n-tooltip>
</li>
</ol>
<template v-if="showControls && showControlsLocal && !streaming">
{{ i18n.baseText('aiAssistant.builder.plan.intro') }}
<div :class="$style.controls">
<n8n-button type="primary" @click="onApprovePlan">{{
i18n.baseText('aiAssistant.builder.plan.approve')
}}</n8n-button>
<n8n-button type="secondary" @click="onRequestChanges">{{
i18n.baseText('aiAssistant.builder.plan.reject')
}}</n8n-button>
</div>
</template>
<template v-if="changesRequested">
<span class="mb-m">{{ i18n.baseText('aiAssistant.builder.plan.whatToChange') }}</span>
</template>
</BaseMessage>
</template>
<style lang="scss" module>
.message {
display: flex;
flex-direction: column;
}
.nodes {
list-style: none;
padding: var(--spacing-2xs);
display: flex;
flex-direction: column;
gap: var(--spacing-2xs);
}
.nodeIcon {
padding: var(--spacing-3xs);
border-radius: var(--border-radius-base);
border: 1px solid var(--color-foreground-base);
display: inline-flex;
}
.node {
display: flex;
align-items: center;
gap: var(--spacing-3xs);
font-size: var(--font-size-2xs);
}
.controls {
display: flex;
gap: var(--spacing-2xs);
margin-top: var(--spacing-xs);
}
.followUpMessage {
margin-top: var(--spacing-m);
}
</style>