feat: Decrease AI Builder wordiness (no-changelog) (#18920)

This commit is contained in:
Mutasem Aldmour
2025-09-01 11:36:12 +02:00
committed by GitHub
parent bf198f8263
commit a9d59ed84a
9 changed files with 350 additions and 82 deletions

View File

@@ -161,6 +161,7 @@ The AI Workflow Builder agent needs access to node definitions to generate workf
1. Run your n8n instance 1. Run your n8n instance
2. Download the node definitions from locally running n8n instance(http://localhost:5678/types/nodes.json) 2. Download the node definitions from locally running n8n instance(http://localhost:5678/types/nodes.json)
3. Save the node definitions to `evaluations/nodes.json` 3. Save the node definitions to `evaluations/nodes.json`
` curl -o evaluations/nodes.json http://localhost:5678/types/nodes.json`
The evaluation will fail with a clear error message if `nodes.json` is missing. The evaluation will fail with a clear error message if `nodes.json` is missing.

View File

@@ -13,7 +13,7 @@ import { findNodeType } from './helpers/validation';
import type { AddedNode } from '../types/nodes'; import type { AddedNode } from '../types/nodes';
import type { AddNodeOutput, ToolError } from '../types/tools'; import type { AddNodeOutput, ToolError } from '../types/tools';
const DISPLAY_TITLE = 'Adding node'; const DISPLAY_TITLE = 'Adding nodes';
/** /**
* Schema for node creation input * Schema for node creation input
@@ -77,7 +77,8 @@ function getCustomNodeTitle(
} }
} }
return DISPLAY_TITLE; // single "node" not plural "nodes" because this pertains to this specific tool call
return 'Adding node';
} }
/** /**

View File

@@ -3,26 +3,21 @@ import { ChatPromptTemplate } from '@langchain/core/prompts';
import { instanceUrlPrompt } from '@/chains/prompts/instance-url'; import { instanceUrlPrompt } from '@/chains/prompts/instance-url';
const systemPrompt = `You are an AI assistant specialized in creating and editing n8n workflows. Your goal is to help users build efficient, well-connected workflows by intelligently using the available tools. const systemPrompt = `You are an AI assistant specialized in creating and editing n8n workflows. Your goal is to help users build efficient, well-connected workflows by intelligently using the available tools.
<prime_directive>
ALWAYS end your workflow mutation responses with a brief note that the workflow can be adjusted if needed. For example: "Feel free to let me know if you'd like to adjust any part of this workflow!" This is mandatory for all workflow mutation responses.
</prime_directive>
<core_principle> <core_principle>
After receiving tool results, reflect on their quality and determine optimal next steps. Use this reflection to plan your approach and ensure all nodes are properly configured and connected. After receiving tool results, reflect on their quality and determine optimal next steps. Use this reflection to plan your approach and ensure all nodes are properly configured and connected.
</core_principle> </core_principle>
<communication_style> <communication_style>
Be warm, helpful, and most importantlyconcise. Focus on actionable information. Keep responses concise.
- Lead with what was accomplished
- Highlight only critical configuration needs CRITICAL: Do NOT provide commentary between tool calls. Execute tools silently.
- Provide clear next steps - NO progress messages like "Perfect!", "Now let me...", "Excellent!"
- Save detailed explanations for when users ask - NO descriptions of what was built or how it works
- One emoji per section maximum - NO workflow features or capabilities explanations
- Only respond AFTER all tools are complete
- Response should only contain setup/usage information
</communication_style> </communication_style>
<tool_execution_strategy>
For maximum efficiency, invoke all relevant tools simultaneously when performing independent operations. This significantly reduces wait time and improves user experience.
Parallel execution guidelines: Parallel execution guidelines:
- ALL tools support parallel execution, including add_nodes - ALL tools support parallel execution, including add_nodes
@@ -316,7 +311,7 @@ When unsure about specific values:
- Add nodes and connections confidently - Add nodes and connections confidently
- For uncertain parameters, use update_node_parameters with clear placeholders - For uncertain parameters, use update_node_parameters with clear placeholders
- For tool nodes with dynamic values, use $fromAI expressions instead of placeholders - For tool nodes with dynamic values, use $fromAI expressions instead of placeholders
- Always mention what needs user input in your response - Always mention what needs user to configure in the setup response
Example for regular nodes: Example for regular nodes:
update_node_parameters({{ update_node_parameters({{
@@ -329,41 +324,30 @@ update_node_parameters({{
nodeId: "gmailTool1", nodeId: "gmailTool1",
instructions: ["Set sendTo to {{ $fromAI('to') }}", "Set subject to {{ $fromAI('subject') }}"] instructions: ["Set sendTo to {{ $fromAI('to') }}", "Set subject to {{ $fromAI('subject') }}"]
}}) }})
Then tell the user: "I've set up the Gmail Tool node with dynamic AI parameters - it will automatically determine recipients and subjects based on context."
</handling_uncertainty> </handling_uncertainty>
`; `;
const responsePatterns = ` const responsePatterns = `
<response_patterns> <response_patterns>
After completing workflow tasks, follow this structure: IMPORTANT: Only provide ONE response AFTER all tool execution is complete.
1. **Brief Summary** (1-2 sentences) Response format:
State what was created/modified without listing every parameter **⚙️ How to Setup** (numbered format)
- List credentials and parameters that need to configured
- Only list incomplete tasks that need user action (skip what's already configured)
2. **Key Requirements** (if any) ** How to Use**
- Credentials needed - Only essential user actions (what to click, where to go)
- Parameters the user should verify
- Any manual configuration required
3. **How to Use** (when relevant) End with: "Let me know if you'd like to adjust anything."
Quick steps to get started
4. **Next Steps** (if applicable) ABSOLUTELY FORBIDDEN IN BUILDING MODE:
What the user might want to do next - Any text between tool calls
- Progress updates during execution
<communication_style> - "Perfect!", "Now let me...", "Excellent!"
Be warm, helpful, and most importantly concise. Focus on actionable information. - Describing what was built
- Lead with what was accomplished - Explaining workflow functionality
- Provide clear next steps
- Highlight only critical configuration needs
- Be warm and encouraging without excessive enthusiasm
- Use emojis sparingly (1-2 max per response)
- Focus on what the user needs to know
- Expand details only when asked
- End with a brief note that the workflow can be adjusted if needed
</communication_style>
</response_patterns> </response_patterns>
`; `;

View File

@@ -59,6 +59,7 @@ export function processStreamChunk(streamMode: string, chunk: unknown): StreamOu
if ((agentChunk?.compact_messages?.messages ?? []).length > 0) { if ((agentChunk?.compact_messages?.messages ?? []).length > 0) {
const lastMessage = const lastMessage =
agentChunk.compact_messages!.messages![agentChunk.compact_messages!.messages!.length - 1]; agentChunk.compact_messages!.messages![agentChunk.compact_messages!.messages!.length - 1];
const messageChunk: AgentMessageChunk = { const messageChunk: AgentMessageChunk = {
role: 'assistant', role: 'assistant',
type: 'message', type: 'message',
@@ -83,13 +84,17 @@ export function processStreamChunk(streamMode: string, chunk: unknown): StreamOu
content = lastMessage.content; content = lastMessage.content;
} }
const messageChunk: AgentMessageChunk = { if (content) {
role: 'assistant', const messageChunk: AgentMessageChunk = {
type: 'message', role: 'assistant',
text: content, type: 'message',
}; text: content,
};
return { messages: [messageChunk] }; return { messages: [messageChunk] };
}
return null;
} }
} }

View File

@@ -86,6 +86,18 @@ describe('stream-processor', () => {
expect(message.text).toBe('Last message to display'); expect(message.text).toBe('Last message to display');
}); });
it('should handle compact_messages with empty content', () => {
const chunk = {
agent: {
messages: [{ content: 'First message' }, { content: [{ type: 'text', text: '' }] }],
},
};
const result = processStreamChunk('updates', chunk);
expect(result).toEqual(null);
});
it('should handle process_operations with workflow update', () => { it('should handle process_operations with workflow update', () => {
const workflowData = { const workflowData = {
nodes: [{ id: 'node1', name: 'Test Node' }], nodes: [{ id: 'node1', name: 'Test Node' }],

View File

@@ -1,9 +1,8 @@
import { render } from '@testing-library/vue'; import { render } from '@testing-library/vue';
import { vi } from 'vitest'; import { vi } from 'vitest';
import { n8nHtml } from '@n8n/design-system/directives';
import AskAssistantChat from './AskAssistantChat.vue'; import AskAssistantChat from './AskAssistantChat.vue';
import { n8nHtml } from '../../directives';
import type { Props as MessageWrapperProps } from './messages/MessageWrapper.vue'; import type { Props as MessageWrapperProps } from './messages/MessageWrapper.vue';
import type { ChatUI } from '../../types/assistant'; import type { ChatUI } from '../../types/assistant';
@@ -388,6 +387,70 @@ describe('AskAssistantChat', () => {
}); });
}); });
it('should collapse tool messages with same toolName with hidden messages in between', () => {
const messages: Array<ChatUI.AssistantMessage & { id: string }> = [
createToolMessage({
id: '1',
status: 'running',
displayTitle: 'Searching...',
updates: [{ type: 'progress', data: { status: 'Initializing search' } }],
}),
{
id: '2',
role: 'assistant',
type: 'agent-suggestion',
title: 'Agent Suggestion',
content: 'This is a suggestion from the agent',
suggestionId: 'test',
quickReplies: [
{ type: 'accept', text: 'Accept suggestion' },
{ type: 'reject', text: 'Reject suggestion' },
],
read: true,
},
createToolMessage({
id: '2',
status: 'running',
displayTitle: 'Still searching...',
customDisplayTitle: 'Custom Search Title',
updates: [{ type: 'progress', data: { status: 'Processing results' } }],
}),
{
id: 'test',
role: 'assistant',
type: 'workflow-updated',
codeSnippet: '',
},
createToolMessage({
id: '3',
status: 'completed',
displayTitle: 'Search Complete',
updates: [{ type: 'output', data: { result: 'Found 10 items' } }],
}),
];
renderWithMessages(messages);
expectMessageWrapperCalledTimes(1);
const props = getMessageWrapperProps();
expectToolMessage(props, {
id: '3',
role: 'assistant',
type: 'tool',
toolName: 'search',
status: 'running',
displayTitle: 'Still searching...',
customDisplayTitle: 'Custom Search Title',
updates: [
{ type: 'progress', data: { status: 'Initializing search' } },
{ type: 'progress', data: { status: 'Processing results' } },
{ type: 'output', data: { result: 'Found 10 items' } },
],
read: true,
});
});
it('should not collapse tool messages with different toolNames', () => { it('should not collapse tool messages with different toolNames', () => {
const messages = [ const messages = [
createToolMessage({ createToolMessage({
@@ -738,4 +801,184 @@ describe('AskAssistantChat', () => {
); );
}); });
}); });
describe('Quick Replies', () => {
const renderWithQuickReplies = (
messages: ChatUI.AssistantMessage[],
streaming = false,
loadingMessage?: string,
) => {
return render(AskAssistantChat, {
global: {
directives: { n8nHtml },
stubs: {
...Object.fromEntries(stubs.map((stub) => [stub, true])),
'n8n-button': { template: '<button><slot></button' },
},
},
props: {
user: { firstName: 'Kobi', lastName: 'Dog' },
messages,
streaming,
loadingMessage,
},
});
};
it('should render quick replies for code-diff message', () => {
const messages: ChatUI.AssistantMessage[] = [
{
id: '1',
role: 'assistant',
type: 'text',
content: 'Here is a solution',
read: true,
},
{
id: '2',
role: 'assistant',
type: 'code-diff',
description: 'Code solution',
codeDiff: 'diff content',
suggestionId: 'test',
quickReplies: [
{ type: 'new-suggestion', text: 'Give me another solution' },
{ type: 'resolved', text: 'All good' },
],
read: true,
},
];
const wrapper = renderWithQuickReplies(messages);
// Quick replies should be rendered (2 buttons found)
expect(wrapper.queryAllByTestId('quick-replies')).toHaveLength(2);
// Quick reply title should be visible
expect(wrapper.container.textContent).toContain('Quick reply');
expect(wrapper.container).toHaveTextContent('Give me another solution');
expect(wrapper.container).toHaveTextContent('All good');
});
it('should render quick replies for agent-suggestion messages', () => {
const messages: ChatUI.AssistantMessage[] = [
{
id: '1',
role: 'assistant',
type: 'text',
content: 'Here is a solution',
read: true,
},
{
id: '2',
role: 'assistant',
type: 'agent-suggestion',
title: 'Agent Suggestion',
content: 'This is a suggestion from the agent',
suggestionId: 'test',
quickReplies: [
{ type: 'accept', text: 'Accept suggestion' },
{ type: 'reject', text: 'Reject suggestion' },
],
read: true,
},
];
const wrapper = renderWithQuickReplies(messages);
// Quick replies should still be rendered even though agent-suggestion is filtered out
expect(wrapper.queryAllByTestId('quick-replies')).toHaveLength(2);
// Quick reply title should be visible
expect(wrapper.container.textContent).toContain('Quick reply');
expect(wrapper.container).toHaveTextContent('Accept suggestion');
expect(wrapper.container).toHaveTextContent('Reject suggestion');
});
it('should not render quick replies when streaming', () => {
const messages: ChatUI.AssistantMessage[] = [
{
id: '1',
role: 'assistant',
type: 'code-diff',
description: 'Code solution',
codeDiff: 'diff content',
suggestionId: 'test',
quickReplies: [{ type: 'new-suggestion', text: 'Give me another solution' }],
read: true,
},
];
const wrapper = renderWithQuickReplies(messages, true);
expect(wrapper.queryAllByTestId('quick-replies')).toHaveLength(0);
expect(wrapper.container.textContent).not.toContain('Quick reply');
expect(wrapper.container.textContent).not.toContain('Give me another solution');
});
it('should not render quick replies for non-last messages', () => {
const messages: ChatUI.AssistantMessage[] = [
{
id: '1',
role: 'assistant',
type: 'code-diff',
description: 'Code solution',
codeDiff: 'diff content',
suggestionId: 'test',
quickReplies: [{ type: 'new-suggestion', text: 'Give me another solution' }],
read: true,
},
{
id: '2',
role: 'assistant',
type: 'text',
content: 'Follow up message',
read: true,
},
];
const wrapper = renderWithQuickReplies(messages);
// Quick replies should not be rendered since the message with quick replies is not last
expect(wrapper.queryAllByTestId('quick-replies')).toHaveLength(0);
expect(wrapper.container.textContent).not.toContain('Quick reply');
expect(wrapper.container.textContent).not.toContain('Give me another solution');
});
it('should not render quick replies when last message has no quickReplies', () => {
const messages: ChatUI.AssistantMessage[] = [
{
id: '1',
role: 'assistant',
type: 'text',
content: 'Simple text message',
read: true,
},
];
const wrapper = renderWithQuickReplies(messages);
expect(wrapper.queryAllByTestId('quick-replies')).toHaveLength(0);
expect(wrapper.container.textContent).not.toContain('Quick reply');
});
it('should not render quick replies when last message has empty quickReplies array', () => {
const messages: ChatUI.AssistantMessage[] = [
{
id: '1',
role: 'assistant',
type: 'code-diff',
description: 'Code solution',
codeDiff: 'diff content',
suggestionId: 'test',
quickReplies: [],
read: true,
},
];
const wrapper = renderWithQuickReplies(messages);
expect(wrapper.queryAllByTestId('quick-replies')).toHaveLength(0);
expect(wrapper.container.textContent).not.toContain('Quick reply');
});
});
}); });

View File

@@ -11,6 +11,7 @@ import AssistantText from '../AskAssistantText/AssistantText.vue';
import InlineAskAssistantButton from '../InlineAskAssistantButton/InlineAskAssistantButton.vue'; import InlineAskAssistantButton from '../InlineAskAssistantButton/InlineAskAssistantButton.vue';
import N8nButton from '../N8nButton'; import N8nButton from '../N8nButton';
import N8nIcon from '../N8nIcon'; import N8nIcon from '../N8nIcon';
import { getSupportedMessageComponent } from './messages/helpers';
const { t } = useI18n(); const { t } = useI18n();
@@ -63,6 +64,11 @@ 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)));
}
function collapseToolMessages(messages: ChatUI.AssistantMessage[]): ChatUI.AssistantMessage[] { function collapseToolMessages(messages: ChatUI.AssistantMessage[]): ChatUI.AssistantMessage[] {
const result: ChatUI.AssistantMessage[] = []; const result: ChatUI.AssistantMessage[] = [];
let i = 0; let i = 0;
@@ -136,7 +142,17 @@ function collapseToolMessages(messages: ChatUI.AssistantMessage[]): ChatUI.Assis
// Ensure all messages have required id and read properties, and collapse tool messages // Ensure all messages have required id and read properties, and collapse tool messages
const normalizedMessages = computed(() => { const normalizedMessages = computed(() => {
const normalized = normalizeMessages(props.messages); const normalized = normalizeMessages(props.messages);
return collapseToolMessages(normalized); return collapseToolMessages(filterOutHiddenMessages(normalized));
});
// Get quickReplies from the last message in the original messages (before filtering)
const lastMessageQuickReplies = computed(() => {
if (!props.messages?.length || props.streaming) return [];
const lastMessage = props.messages[props.messages.length - 1];
return 'quickReplies' in lastMessage && lastMessage.quickReplies?.length
? lastMessage.quickReplies
: [];
}); });
const textInputValue = ref<string>(''); const textInputValue = ref<string>('');
@@ -252,18 +268,17 @@ watch(
/> />
<div <div
v-if=" v-if="lastMessageQuickReplies.length && i === normalizedMessages.length - 1"
!streaming &&
'quickReplies' in message &&
message.quickReplies?.length &&
i === normalizedMessages.length - 1
"
:class="$style.quickReplies" :class="$style.quickReplies"
> >
<div :class="$style.quickRepliesTitle"> <div :class="$style.quickRepliesTitle">
{{ t('assistantChat.quickRepliesTitle') }} {{ t('assistantChat.quickRepliesTitle') }}
</div> </div>
<div v-for="opt in message.quickReplies" :key="opt.type" data-test-id="quick-replies"> <div
v-for="opt in lastMessageQuickReplies"
:key="opt.type"
data-test-id="quick-replies"
>
<N8nButton <N8nButton
v-if="opt.text" v-if="opt.text"
type="secondary" type="secondary"

View File

@@ -3,12 +3,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, type Component } from 'vue'; import { computed, type Component } from 'vue';
import BlockMessage from './BlockMessage.vue'; import { getSupportedMessageComponent } from './helpers';
import CodeDiffMessage from './CodeDiffMessage.vue';
import ErrorMessage from './ErrorMessage.vue';
import EventMessage from './EventMessage.vue';
import TextMessage from './TextMessage.vue';
import ToolMessage from './ToolMessage.vue';
import type { ChatUI, RatingFeedback } from '../../../types/assistant'; import type { ChatUI, RatingFeedback } from '../../../types/assistant';
export interface Props { export interface Props {
@@ -31,25 +26,7 @@ const emit = defineEmits<{
}>(); }>();
const messageComponent = computed<Component | null>(() => { const messageComponent = computed<Component | null>(() => {
switch (props.message.type) { return getSupportedMessageComponent(props.message.type);
case 'text':
return TextMessage;
case 'block':
return BlockMessage;
case 'code-diff':
return CodeDiffMessage;
case 'error':
return ErrorMessage;
case 'event':
return EventMessage;
case 'tool':
return ToolMessage;
case 'agent-suggestion':
case 'workflow-updated':
return null;
default:
return null;
}
}); });
</script> </script>

View File

@@ -0,0 +1,30 @@
import type { ChatUI } from '@n8n/design-system/types';
import BlockMessage from './BlockMessage.vue';
import CodeDiffMessage from './CodeDiffMessage.vue';
import ErrorMessage from './ErrorMessage.vue';
import EventMessage from './EventMessage.vue';
import TextMessage from './TextMessage.vue';
import ToolMessage from './ToolMessage.vue';
export function getSupportedMessageComponent(type: ChatUI.AssistantMessage['type']) {
switch (type) {
case 'text':
return TextMessage;
case 'block':
return BlockMessage;
case 'code-diff':
return CodeDiffMessage;
case 'error':
return ErrorMessage;
case 'event':
return EventMessage;
case 'tool':
return ToolMessage;
case 'agent-suggestion':
case 'workflow-updated':
return null;
default:
return null;
}
}