chore: Remove AI WF Builder planner step (no-changelog) (#19343)

This commit is contained in:
oleg
2025-09-11 08:56:38 +02:00
committed by GitHub
parent df31868da5
commit a0d92a72b9
19 changed files with 14 additions and 1719 deletions

View File

@@ -200,10 +200,6 @@
"aiAssistant.builder.canvasPrompt.startManually.title": "Start manually",
"aiAssistant.builder.canvasPrompt.startManually.subTitle": "Add the first node",
"aiAssistant.builder.streamAbortedMessage": "[Task aborted]",
"aiAssistant.builder.plan.intro": "Do you want to proceed with this plan?",
"aiAssistant.builder.plan.approve": "Approve Plan",
"aiAssistant.builder.plan.reject": "Request Changes",
"aiAssistant.builder.plan.whatToChange": "What would you like to change?",
"aiAssistant.assistant": "AI Assistant",
"aiAssistant.newSessionModal.title.part1": "Start new",
"aiAssistant.newSessionModal.title.part2": "session",

View File

@@ -26,7 +26,6 @@ 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: {
@@ -978,384 +977,6 @@ describe('AskAssistantBuild', () => {
});
});
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');
});
});
it('should handle multiple canvas generations correctly', async () => {
const originalWorkflow = {
nodes: [],

View File

@@ -1,19 +1,16 @@
<script lang="ts" setup>
import { useBuilderStore } from '@/stores/builder.store';
import { useUsersStore } from '@/stores/users.store';
import { computed, watch, ref, nextTick } from 'vue';
import { computed, watch, ref } 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 { ChatUI, RatingFeedback } from '@n8n/design-system/types/assistant';
import type { 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: [];
@@ -31,7 +28,6 @@ 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 workflowUpdated = ref<{ start: string; end: string } | undefined>();
@@ -81,40 +77,6 @@ function onFeedback(feedback: RatingFeedback) {
}
}
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;
}
function dedupeToolNames(toolNames: string[]): string[] {
return [...new Set(toolNames)];
}
@@ -175,12 +137,6 @@ 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 },
);
@@ -229,7 +185,6 @@ watch(currentRoute, () => {
:user="user"
:messages="builderStore.chatMessages"
:streaming="builderStore.streaming"
:disabled="planStatus === 'pending'"
:loading-message="loadingMessage"
:mode="i18n.baseText('aiAssistant.builder.mode')"
:title="'n8n AI'"
@@ -245,16 +200,6 @@ 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

@@ -1,134 +0,0 @@
<!-- 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>

View File

@@ -1,12 +1,7 @@
import type { ChatUI } from '@n8n/design-system/types/assistant';
import type { ChatRequest } from '@/types/assistant.types';
import { useI18n } from '@n8n/i18n';
import {
isTextMessage,
isWorkflowUpdatedMessage,
isToolMessage,
isPlanMessage,
} from '@/types/assistant.types';
import { isTextMessage, isWorkflowUpdatedMessage, isToolMessage } from '@/types/assistant.types';
export interface MessageProcessingResult {
messages: ChatUI.AssistantMessage[];
@@ -124,18 +119,6 @@ export function useBuilderMessages() {
// Don't clear thinking for workflow updates - they're just state changes
} else if (isToolMessage(msg)) {
processToolMessage(messages, msg, messageId);
} else if (isPlanMessage(msg)) {
// Add new plan message
messages.push({
id: messageId,
role: 'assistant',
type: 'custom',
customType: 'nodesPlan',
message: msg.message,
data: msg.plan,
} satisfies ChatUI.CustomMessage);
// Plan messages are informational, clear thinking
shouldClearThinking = true;
} else if ('type' in msg && msg.type === 'error' && 'content' in msg) {
// Handle error messages from the API
// API sends error messages with type: 'error' and content field
@@ -390,17 +373,6 @@ export function useBuilderMessages() {
} satisfies ChatUI.AssistantMessage;
}
if (isPlanMessage(message)) {
return {
id,
role: 'assistant',
type: 'custom',
customType: 'nodesPlan',
data: message.plan,
message: message.message,
} satisfies ChatUI.CustomMessage;
}
// Handle event messages
if ('type' in message && message.type === 'event') {
return {

View File

@@ -166,17 +166,6 @@ export namespace ChatRequest {
}
// API-only types
export interface PlanMessage {
role: 'assistant';
type: 'plan';
plan: Array<{
nodeType: string;
nodeName: string;
reasoning: string;
}>;
message?: string; // For plan-review messages
}
export type MessageResponse =
| ((
| TextMessage
@@ -187,7 +176,6 @@ export namespace ChatRequest {
| ChatUI.WorkflowUpdatedMessage
| ToolMessage
| ChatUI.ErrorMessage
| PlanMessage
) & {
quickReplies?: ChatUI.QuickReply[];
})
@@ -277,7 +265,3 @@ export function isEndSessionMessage(
): msg is ChatUI.EndSessionMessage {
return 'type' in msg && msg.type === 'event' && msg.eventName === 'end-session';
}
export function isPlanMessage(msg: ChatRequest.MessageResponse): msg is ChatRequest.PlanMessage {
return 'type' in msg && msg.type === 'plan' && 'plan' in msg && Array.isArray(msg.plan);
}