mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-17 01:56:46 +00:00
feat: Add planning step to AI workflow builder (no-changelog) (#18737)
Co-authored-by: Eugene Molodkin <eugene@n8n.io>
This commit is contained in:
@@ -24,6 +24,7 @@ interface Props {
|
||||
};
|
||||
messages?: ChatUI.AssistantMessage[];
|
||||
streaming?: boolean;
|
||||
disabled?: boolean;
|
||||
loadingMessage?: string;
|
||||
sessionId?: string;
|
||||
title?: string;
|
||||
@@ -66,7 +67,9 @@ function normalizeMessages(messages: ChatUI.AssistantMessage[]): ChatUI.Assistan
|
||||
|
||||
// filter out these messages so that tool collapsing works correctly
|
||||
function filterOutHiddenMessages(messages: ChatUI.AssistantMessage[]): ChatUI.AssistantMessage[] {
|
||||
return messages.filter((message) => Boolean(getSupportedMessageComponent(message.type)));
|
||||
return messages.filter(
|
||||
(message) => Boolean(getSupportedMessageComponent(message.type)) || message.type === 'custom',
|
||||
);
|
||||
}
|
||||
|
||||
function collapseToolMessages(messages: ChatUI.AssistantMessage[]): ChatUI.AssistantMessage[] {
|
||||
@@ -165,7 +168,7 @@ const sessionEnded = computed(() => {
|
||||
});
|
||||
|
||||
const sendDisabled = computed(() => {
|
||||
return !textInputValue.value || props.streaming || sessionEnded.value;
|
||||
return !textInputValue.value || props.streaming || sessionEnded.value || props.disabled;
|
||||
});
|
||||
|
||||
const showPlaceholder = computed(() => {
|
||||
@@ -226,6 +229,13 @@ watch(
|
||||
},
|
||||
{ immediate: true, deep: true },
|
||||
);
|
||||
|
||||
// Expose focusInput method to parent components
|
||||
defineExpose({
|
||||
focusInput: () => {
|
||||
chatInput.value?.focus();
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -265,7 +275,11 @@ watch(
|
||||
@code-replace="() => emit('codeReplace', i)"
|
||||
@code-undo="() => emit('codeUndo', i)"
|
||||
@feedback="onRateMessage"
|
||||
/>
|
||||
>
|
||||
<template v-if="$slots['custom-message']" #custom-message="customMessageProps">
|
||||
<slot name="custom-message" v-bind="customMessageProps" />
|
||||
</template>
|
||||
</MessageWrapper>
|
||||
|
||||
<div
|
||||
v-if="lastMessageQuickReplies.length && i === normalizedMessages.length - 1"
|
||||
@@ -336,8 +350,8 @@ watch(
|
||||
ref="chatInput"
|
||||
v-model="textInputValue"
|
||||
class="ignore-key-press-node-creator ignore-key-press-canvas"
|
||||
:class="{ [$style.disabled]: sessionEnded || streaming }"
|
||||
:disabled="sessionEnded || streaming"
|
||||
:class="{ [$style.disabled]: sessionEnded || streaming || disabled }"
|
||||
:disabled="sessionEnded || streaming || disabled"
|
||||
:placeholder="placeholder ?? t('assistantChat.inputPlaceholder')"
|
||||
rows="1"
|
||||
wrap="hard"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -31,16 +31,27 @@ const messageComponent = computed<Component | null>(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<component
|
||||
:is="messageComponent"
|
||||
v-if="messageComponent"
|
||||
:message="message"
|
||||
:is-first-of-role="isFirstOfRole"
|
||||
:user="user"
|
||||
:streaming="streaming"
|
||||
:is-last-message="isLastMessage"
|
||||
@code-replace="emit('codeReplace')"
|
||||
@code-undo="emit('codeUndo')"
|
||||
@feedback="(feedback: RatingFeedback) => emit('feedback', feedback)"
|
||||
/>
|
||||
<div>
|
||||
<component
|
||||
:is="messageComponent"
|
||||
v-if="messageComponent"
|
||||
:message="message"
|
||||
:is-first-of-role="isFirstOfRole"
|
||||
:user="user"
|
||||
:streaming="streaming"
|
||||
:is-last-message="isLastMessage"
|
||||
@code-replace="emit('codeReplace')"
|
||||
@code-undo="emit('codeUndo')"
|
||||
@feedback="(feedback: RatingFeedback) => emit('feedback', feedback)"
|
||||
/>
|
||||
<slot
|
||||
v-else-if="message.type === 'custom'"
|
||||
name="custom-message"
|
||||
:message="message"
|
||||
:is-first-of-role="isFirstOfRole"
|
||||
:user="user"
|
||||
:streaming="streaming"
|
||||
:is-last-message="isLastMessage"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -23,6 +23,7 @@ export function getSupportedMessageComponent(type: ChatUI.AssistantMessage['type
|
||||
return ToolMessage;
|
||||
case 'agent-suggestion':
|
||||
case 'workflow-updated':
|
||||
case 'custom':
|
||||
return null;
|
||||
default:
|
||||
return null;
|
||||
|
||||
@@ -87,6 +87,15 @@ export namespace ChatUI {
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface CustomMessage {
|
||||
id?: string;
|
||||
role: 'assistant' | 'user';
|
||||
type: 'custom';
|
||||
message?: string;
|
||||
customType: string;
|
||||
data: unknown;
|
||||
}
|
||||
|
||||
type MessagesWithReplies = (
|
||||
| TextMessage
|
||||
| CodeDiffMessage
|
||||
@@ -106,6 +115,7 @@ export namespace ChatUI {
|
||||
| AgentSuggestionMessage
|
||||
| WorkflowUpdatedMessage
|
||||
| ToolMessage
|
||||
| CustomMessage
|
||||
) & {
|
||||
id?: string;
|
||||
read?: boolean;
|
||||
@@ -186,6 +196,12 @@ export function isToolMessage(
|
||||
return msg.type === 'tool';
|
||||
}
|
||||
|
||||
export function isCustomMessage(
|
||||
msg: ChatUI.AssistantMessage,
|
||||
): msg is ChatUI.CustomMessage & { id?: string; read?: boolean } {
|
||||
return msg.type === 'custom';
|
||||
}
|
||||
|
||||
// Helper to ensure message has required id and read properties
|
||||
export function hasRequiredProps<T extends ChatUI.AssistantMessage>(
|
||||
msg: T,
|
||||
|
||||
@@ -199,6 +199,10 @@
|
||||
"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",
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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>
|
||||
@@ -1,7 +1,12 @@
|
||||
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 } from '@/types/assistant.types';
|
||||
import {
|
||||
isTextMessage,
|
||||
isWorkflowUpdatedMessage,
|
||||
isToolMessage,
|
||||
isPlanMessage,
|
||||
} from '@/types/assistant.types';
|
||||
|
||||
export interface MessageProcessingResult {
|
||||
messages: ChatUI.AssistantMessage[];
|
||||
@@ -119,6 +124,18 @@ 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
|
||||
@@ -145,7 +162,7 @@ export function useBuilderMessages() {
|
||||
messageId: string,
|
||||
): void {
|
||||
// Use toolCallId as the message ID for consistency across updates
|
||||
const toolMessageId = msg.toolCallId || messageId;
|
||||
const toolMessageId = msg.toolCallId ?? messageId;
|
||||
|
||||
// Check if we already have this tool message
|
||||
const existingIndex = msg.toolCallId
|
||||
@@ -214,14 +231,15 @@ export function useBuilderMessages() {
|
||||
}
|
||||
}
|
||||
|
||||
// Check if there's any text message after the last completed tool
|
||||
// Check if there's any text or custom message after the last completed tool
|
||||
// Note: workflow-updated messages shouldn't count as they're just canvas state updates
|
||||
let hasTextAfterTools = false;
|
||||
// Custom messages (like plan messages) should count as responses
|
||||
let hasResponseAfterTools = false;
|
||||
if (lastCompletedToolIndex !== -1) {
|
||||
for (let i = lastCompletedToolIndex + 1; i < messages.length; i++) {
|
||||
const msg = messages[i];
|
||||
if (msg.type === 'text') {
|
||||
hasTextAfterTools = true;
|
||||
if (msg.type === 'text' || msg.type === 'custom') {
|
||||
hasResponseAfterTools = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -229,7 +247,7 @@ export function useBuilderMessages() {
|
||||
|
||||
return {
|
||||
hasAnyRunningTools: false,
|
||||
isStillThinking: hasCompletedTools && !hasTextAfterTools,
|
||||
isStillThinking: hasCompletedTools && !hasResponseAfterTools,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -372,6 +390,17 @@ 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 {
|
||||
|
||||
@@ -886,6 +886,7 @@ export const ASK_AI_MAX_PROMPT_LENGTH = 600;
|
||||
export const ASK_AI_MIN_PROMPT_LENGTH = 15;
|
||||
export const ASK_AI_LOADING_DURATION_MS = 12000;
|
||||
export const ASK_AI_SLIDE_OUT_DURATION_MS = 200;
|
||||
export const PLAN_APPROVAL_MESSAGE = 'Proceed with the plan';
|
||||
|
||||
export const APPEND_ATTRIBUTION_DEFAULT_PATH = 'parameters.options.appendAttribution';
|
||||
|
||||
|
||||
@@ -94,6 +94,10 @@ export const useBuilderStore = defineStore(STORES.BUILDER, () => {
|
||||
|
||||
const workflowMessages = computed(() => chatMessages.value.filter(isWorkflowUpdatedMessage));
|
||||
|
||||
const assistantMessages = computed(() =>
|
||||
chatMessages.value.filter((msg) => msg.role === 'assistant'),
|
||||
);
|
||||
|
||||
// Chat management functions
|
||||
/**
|
||||
* Resets the entire chat session to initial state.
|
||||
@@ -439,6 +443,7 @@ export const useBuilderStore = defineStore(STORES.BUILDER, () => {
|
||||
workflowPrompt,
|
||||
toolMessages,
|
||||
workflowMessages,
|
||||
assistantMessages,
|
||||
trackingSessionId,
|
||||
streamingAbortController,
|
||||
initialGeneration,
|
||||
|
||||
@@ -166,6 +166,16 @@ 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 =
|
||||
| ((
|
||||
@@ -177,6 +187,7 @@ export namespace ChatRequest {
|
||||
| ChatUI.WorkflowUpdatedMessage
|
||||
| ToolMessage
|
||||
| ChatUI.ErrorMessage
|
||||
| PlanMessage
|
||||
) & {
|
||||
quickReplies?: ChatUI.QuickReply[];
|
||||
})
|
||||
@@ -266,3 +277,7 @@ 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);
|
||||
}
|
||||
|
||||
@@ -1882,7 +1882,9 @@ watch(
|
||||
};
|
||||
|
||||
fallbackNodes.value =
|
||||
builderStore.isAIBuilderEnabled && builderStore.isAssistantEnabled
|
||||
builderStore.isAIBuilderEnabled &&
|
||||
builderStore.isAssistantEnabled &&
|
||||
builderStore.assistantMessages.length === 0
|
||||
? [aiPromptItem]
|
||||
: [addNodesItem];
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user