feat: Simplify builder tool calls (no-changelog) (#18798)

This commit is contained in:
Mutasem Aldmour
2025-08-28 10:14:03 +02:00
committed by GitHub
parent ff56c95605
commit d244b99484
36 changed files with 2190 additions and 391 deletions

View File

@@ -635,6 +635,110 @@ ToolMessageError.args = {
]),
};
const SEARCH_FILES_TOOL_CALL_COMPLETED: ChatUI.AssistantMessage = {
id: '128',
type: 'tool',
role: 'assistant',
toolName: 'search_files',
toolCallId: 'call_456',
status: 'completed',
displayTitle: 'Searching files',
customDisplayTitle: 'Searching for Reddit node',
updates: [
{
type: 'input',
data: {
pattern: '*.vue',
directory: '/src',
},
timestamp: new Date().toISOString(),
},
{
type: 'progress',
data: { message: 'Searching for Vue files...' },
timestamp: new Date().toISOString(),
},
{
type: 'output',
data: {
files: ['/src/components/Button.vue', '/src/components/Modal.vue', '/src/views/Home.vue'],
count: 3,
},
timestamp: new Date().toISOString(),
},
],
read: false,
};
const SEARCH_FILES_TOOL_CALL_COMPLETED_2: ChatUI.AssistantMessage = {
...SEARCH_FILES_TOOL_CALL_COMPLETED,
displayTitle: 'Searching nodes',
customDisplayTitle: 'Searching for Spotify node',
};
const SEARCH_FILES_TOOL_CALL_RUNNING: ChatUI.AssistantMessage = {
...SEARCH_FILES_TOOL_CALL_COMPLETED,
status: 'running',
customDisplayTitle: 'Searching for Open AI nodes',
};
const SEARCH_FILES_TOOL_CALL_RUNNING_2: ChatUI.AssistantMessage = {
...SEARCH_FILES_TOOL_CALL_COMPLETED,
status: 'running',
customDisplayTitle: 'Searching for Slack node',
};
const SEARCH_FILES_TOOL_CALL_ERROR: ChatUI.AssistantMessage = {
...SEARCH_FILES_TOOL_CALL_COMPLETED,
status: 'error',
customDisplayTitle: 'Searching for Power node',
};
const SEARCH_FILES_TOOL_CALL_ERROR_2: ChatUI.AssistantMessage = {
...SEARCH_FILES_TOOL_CALL_COMPLETED,
status: 'error',
customDisplayTitle: 'Searching for n8n node',
};
function getMessage(content: string): ChatUI.AssistantMessage {
return {
id: '130',
type: 'text',
role: 'user',
content,
read: true,
};
}
export const ToolMessageMultiple = Template.bind({});
ToolMessageMultiple.args = {
user: {
firstName: 'Max',
lastName: 'Test',
},
messages: getMessages([
getMessage('Collapse multiple consecutive completed tool calls into one'),
SEARCH_FILES_TOOL_CALL_COMPLETED,
SEARCH_FILES_TOOL_CALL_COMPLETED_2,
getMessage('Collapse multiple consecutive completed and running tool calls into one'),
SEARCH_FILES_TOOL_CALL_COMPLETED,
SEARCH_FILES_TOOL_CALL_RUNNING,
SEARCH_FILES_TOOL_CALL_RUNNING_2,
getMessage('Collapse multiple consecutive error and running tool calls into running'),
SEARCH_FILES_TOOL_CALL_ERROR,
SEARCH_FILES_TOOL_CALL_RUNNING,
getMessage('Collapse multiple consecutive error and completed tool calls into completed'),
SEARCH_FILES_TOOL_CALL_ERROR,
SEARCH_FILES_TOOL_CALL_COMPLETED,
getMessage('Collapse multiple consecutive running tool calls into one running'),
SEARCH_FILES_TOOL_CALL_RUNNING,
SEARCH_FILES_TOOL_CALL_RUNNING_2,
getMessage('Collapse multiple consecutive error tool calls into one error'),
SEARCH_FILES_TOOL_CALL_ERROR,
SEARCH_FILES_TOOL_CALL_ERROR_2,
]),
};
export const MixedMessagesWithTools = Template.bind({});
MixedMessagesWithTools.args = {
user: {

View File

@@ -1,8 +1,11 @@
import { render } from '@testing-library/vue';
import { vi } from 'vitest';
import { n8nHtml } from '@n8n/design-system/directives';
import AskAssistantChat from './AskAssistantChat.vue';
import type { Props as MessageWrapperProps } from './messages/MessageWrapper.vue';
import type { ChatUI } from '../../types/assistant';
const stubs = ['n8n-avatar', 'n8n-button', 'n8n-icon', 'n8n-icon-button'];
@@ -255,4 +258,484 @@ describe('AskAssistantChat', () => {
const textarea = wrapper.queryByTestId('chat-input');
expect(textarea).toHaveAttribute('maxLength', '100');
});
describe('collapseToolMessages', () => {
const MessageWrapperMock = vi.fn(() => ({
template: '<div data-testid="message-wrapper-mock"></div>',
}));
const stubsWithMessageWrapper = {
...Object.fromEntries(stubs.map((stub) => [stub, true])),
MessageWrapper: MessageWrapperMock,
};
const createToolMessage = (
overrides: Partial<ChatUI.ToolMessage & { id: string }> = {},
): ChatUI.ToolMessage & { id: string } => ({
id: '1',
role: 'assistant',
type: 'tool',
toolName: 'search',
status: 'completed',
displayTitle: 'Search Results',
updates: [{ type: 'output', data: { result: 'Found items' } }],
...overrides,
});
const renderWithMessages = (messages: ChatUI.AssistantMessage[], extraProps = {}) => {
MessageWrapperMock.mockClear();
return render(AskAssistantChat, {
global: { stubs: stubsWithMessageWrapper },
props: {
user: { firstName: 'Kobi', lastName: 'Dog' },
messages,
...extraProps,
},
});
};
const renderWithDirectives = (messages: ChatUI.AssistantMessage[], extraProps = {}) => {
MessageWrapperMock.mockClear();
return render(AskAssistantChat, {
global: {
directives: { n8nHtml },
stubs: stubsWithMessageWrapper,
},
props: {
user: { firstName: 'Kobi', lastName: 'Dog' },
messages,
...extraProps,
},
});
};
const getMessageWrapperProps = (callIndex = 0): MessageWrapperProps => {
const mockCall = MessageWrapperMock.mock.calls[callIndex];
expect(mockCall).toBeDefined();
return (mockCall as unknown as [props: MessageWrapperProps])[0];
};
const expectMessageWrapperCalledTimes = (times: number) => {
expect(MessageWrapperMock).toHaveBeenCalledTimes(times);
};
const expectToolMessage = (
props: MessageWrapperProps,
expectedProps: Partial<ChatUI.ToolMessage & { id: string; read?: boolean }>,
) => {
expect(props.message).toEqual(expect.objectContaining(expectedProps));
};
it('should not collapse single tool message', () => {
const message = createToolMessage({
id: '1',
displayTitle: 'Search Results',
updates: [{ type: 'output', data: { result: 'Found 10 items' } }],
});
renderWithMessages([message]);
expectMessageWrapperCalledTimes(1);
const props = getMessageWrapperProps();
expectToolMessage(props, {
...message,
read: true,
});
});
it('should collapse consecutive tool messages with same toolName', () => {
const messages = [
createToolMessage({
id: '1',
status: 'running',
displayTitle: 'Searching...',
updates: [{ type: 'progress', data: { status: 'Initializing search' } }],
}),
createToolMessage({
id: '2',
status: 'running',
displayTitle: 'Still searching...',
customDisplayTitle: 'Custom Search Title',
updates: [{ type: 'progress', data: { status: 'Processing results' } }],
}),
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', () => {
const messages = [
createToolMessage({
id: '1',
toolName: 'search',
displayTitle: 'Search Results',
updates: [{ type: 'output', data: { result: 'Found 10 items' } }],
}),
createToolMessage({
id: '2',
toolName: 'fetch',
displayTitle: 'Data Fetched',
updates: [{ type: 'output', data: { result: 'Data retrieved' } }],
}),
];
renderWithMessages(messages);
expectMessageWrapperCalledTimes(2);
const firstProps = getMessageWrapperProps(0);
expectToolMessage(firstProps, {
id: '1',
toolName: 'search',
status: 'completed',
displayTitle: 'Search Results',
});
const secondProps = getMessageWrapperProps(1);
expectToolMessage(secondProps, {
id: '2',
toolName: 'fetch',
status: 'completed',
displayTitle: 'Data Fetched',
});
});
it('should collapse completed and error statuses', () => {
const messages = [
createToolMessage({
id: '1',
status: 'completed',
displayTitle: 'Search Complete',
updates: [{ type: 'output', data: { result: 'Found some items' } }],
}),
createToolMessage({
id: '2',
status: 'error',
displayTitle: 'Search error',
customDisplayTitle: 'Custom Running Title',
updates: [{ type: 'progress', data: { status: 'Processing more results' } }],
}),
createToolMessage({
id: '3',
status: 'completed',
displayTitle: 'Final Search Complete',
updates: [{ type: 'output', data: { result: 'All done' } }],
}),
];
renderWithMessages(messages);
expectMessageWrapperCalledTimes(1);
const props = getMessageWrapperProps();
expectToolMessage(props, {
id: '3',
status: 'error',
displayTitle: 'Search error',
customDisplayTitle: undefined,
updates: [
{ type: 'output', data: { result: 'Found some items' } },
{ type: 'progress', data: { status: 'Processing more results' } },
{ type: 'output', data: { result: 'All done' } },
],
});
});
it('should collapse running, completed and error statuses into running', () => {
const messages = [
createToolMessage({
id: '1',
status: 'running',
displayTitle: 'Search Running',
customDisplayTitle: 'Custom Search Title',
updates: [{ type: 'output', data: { result: 'Found some items' } }],
}),
createToolMessage({
id: '2',
status: 'error',
displayTitle: 'Search error',
customDisplayTitle: 'Custom Error Title',
updates: [{ type: 'progress', data: { status: 'Processing more results' } }],
}),
createToolMessage({
id: '3',
status: 'completed',
displayTitle: 'Final Search Complete',
updates: [{ type: 'output', data: { result: 'All done' } }],
}),
];
renderWithMessages(messages);
expectMessageWrapperCalledTimes(1);
const props = getMessageWrapperProps();
expectToolMessage(props, {
id: '3',
role: 'assistant',
type: 'tool',
toolName: 'search',
status: 'running',
displayTitle: 'Search Running',
customDisplayTitle: 'Custom Search Title',
updates: [
{ type: 'output', data: { result: 'Found some items' } },
{ type: 'progress', data: { status: 'Processing more results' } },
{ type: 'output', data: { result: 'All done' } },
],
read: true,
});
});
it('should preserve running status when collapsing messages with running status', () => {
const messages = [
createToolMessage({
id: '1',
status: 'completed',
displayTitle: 'Search Complete',
updates: [{ type: 'output', data: { result: 'Found some items' } }],
}),
createToolMessage({
id: '2',
status: 'running',
displayTitle: 'Still searching...',
customDisplayTitle: 'Custom Running Title',
updates: [{ type: 'progress', data: { status: 'Processing more results' } }],
}),
createToolMessage({
id: '3',
status: 'completed',
displayTitle: 'Final Search Complete',
updates: [{ type: 'output', data: { result: 'All done' } }],
}),
];
renderWithMessages(messages);
expectMessageWrapperCalledTimes(1);
const props = getMessageWrapperProps();
expectToolMessage(props, {
id: '3',
status: 'running',
displayTitle: 'Still searching...',
customDisplayTitle: 'Custom Running Title',
updates: [
{ type: 'output', data: { result: 'Found some items' } },
{ type: 'progress', data: { status: 'Processing more results' } },
{ type: 'output', data: { result: 'All done' } },
],
});
});
it('should combine all updates from collapsed messages', () => {
const messages = [
createToolMessage({
id: '1',
status: 'running',
displayTitle: 'Searching...',
updates: [
{ type: 'progress', data: { status: 'Starting search' } },
{ type: 'input', data: { query: 'test query' } },
],
}),
createToolMessage({
id: '2',
status: 'completed',
displayTitle: 'Search Complete',
updates: [
{ type: 'progress', data: { status: 'Processing results' } },
{ type: 'output', data: { result: 'Found 10 items' } },
],
}),
];
renderWithMessages(messages);
expectMessageWrapperCalledTimes(1);
const props = getMessageWrapperProps();
const toolMessage = props.message as ChatUI.ToolMessage;
expect(toolMessage.status).toEqual('running');
expect(toolMessage.updates).toEqual([
{ type: 'progress', data: { status: 'Starting search' } },
{ type: 'input', data: { query: 'test query' } },
{ type: 'progress', data: { status: 'Processing results' } },
{ type: 'output', data: { result: 'Found 10 items' } },
]);
});
it('should not collapse tool messages separated by non-tool messages', () => {
const messages = [
createToolMessage({
id: '1',
status: 'completed',
displayTitle: 'First Search',
updates: [{ type: 'output', data: { result: 'First result' } }],
}),
{
id: '2',
role: 'assistant' as const,
type: 'text' as const,
content: 'Here are the search results',
},
createToolMessage({
id: '3',
status: 'completed',
displayTitle: 'Second Search',
updates: [{ type: 'output', data: { result: 'Second result' } }],
}),
];
renderWithDirectives(messages);
expectMessageWrapperCalledTimes(3);
const firstProps = getMessageWrapperProps(0);
expectToolMessage(firstProps, {
id: '1',
type: 'tool',
toolName: 'search',
displayTitle: 'First Search',
});
const secondProps = getMessageWrapperProps(1);
expect(secondProps.message).toEqual(
expect.objectContaining({
id: '2',
type: 'text',
content: 'Here are the search results',
}),
);
const thirdProps = getMessageWrapperProps(2);
expectToolMessage(thirdProps, {
id: '3',
type: 'tool',
toolName: 'search',
displayTitle: 'Second Search',
});
});
it('should handle customDisplayTitle correctly for running status', () => {
const messages = [
createToolMessage({
id: '1',
status: 'completed',
displayTitle: 'Search Complete',
customDisplayTitle: 'Should be ignored for completed',
updates: [{ type: 'output', data: { result: 'Found items' } }],
}),
createToolMessage({
id: '2',
status: 'running',
displayTitle: 'Searching...',
customDisplayTitle: 'Custom Running Title',
updates: [{ type: 'progress', data: { status: 'In progress' } }],
}),
];
renderWithMessages(messages);
expectMessageWrapperCalledTimes(1);
const props = getMessageWrapperProps();
expectToolMessage(props, {
status: 'running',
displayTitle: 'Searching...',
customDisplayTitle: 'Custom Running Title',
});
});
it('should handle mixed message types correctly', () => {
const messages = [
{
id: '1',
role: 'user' as const,
type: 'text' as const,
content: 'Please search for something',
},
createToolMessage({
id: '2',
status: 'running',
displayTitle: 'Searching...',
updates: [{ type: 'progress', data: { status: 'Starting' } }],
}),
createToolMessage({
id: '3',
status: 'completed',
displayTitle: 'Search Complete',
updates: [{ type: 'output', data: { result: 'Found results' } }],
}),
{
id: '4',
role: 'assistant' as const,
type: 'text' as const,
content: 'Here are your search results',
},
];
renderWithDirectives(messages);
expectMessageWrapperCalledTimes(3);
const firstProps = getMessageWrapperProps(0);
expect(firstProps.message).toEqual(
expect.objectContaining({
id: '1',
role: 'user',
type: 'text',
content: 'Please search for something',
}),
);
const secondProps = getMessageWrapperProps(1);
expectToolMessage(secondProps, {
id: '3',
role: 'assistant',
type: 'tool',
toolName: 'search',
status: 'running',
updates: [
{ type: 'progress', data: { status: 'Starting' } },
{ type: 'output', data: { result: 'Found results' } },
],
});
const thirdProps = getMessageWrapperProps(2);
expect(thirdProps.message).toEqual(
expect.objectContaining({
id: '4',
role: 'assistant',
type: 'text',
content: 'Here are your search results',
}),
);
});
});
});

View File

@@ -4,6 +4,7 @@ import { computed, nextTick, ref, watch } from 'vue';
import MessageWrapper from './messages/MessageWrapper.vue';
import { useI18n } from '../../composables/useI18n';
import type { ChatUI, RatingFeedback } from '../../types/assistant';
import { isToolMessage } from '../../types/assistant';
import AssistantIcon from '../AskAssistantIcon/AssistantIcon.vue';
import AssistantLoadingMessage from '../AskAssistantLoadingMessage/AssistantLoadingMessage.vue';
import AssistantText from '../AskAssistantText/AssistantText.vue';
@@ -54,13 +55,88 @@ const props = withDefaults(defineProps<Props>(), {
scrollOnNewMessage: false,
});
// Ensure all messages have required id and read properties
const normalizedMessages = computed(() => {
return props.messages.map((msg, index) => ({
function normalizeMessages(messages: ChatUI.AssistantMessage[]): ChatUI.AssistantMessage[] {
return messages.map((msg, index) => ({
...msg,
id: msg.id || `msg-${index}`,
read: msg.read ?? true,
}));
}
function collapseToolMessages(messages: ChatUI.AssistantMessage[]): ChatUI.AssistantMessage[] {
const result: ChatUI.AssistantMessage[] = [];
let i = 0;
while (i < messages.length) {
const currentMsg = messages[i];
// If it's not a tool message, add it as-is and continue
if (!isToolMessage(currentMsg)) {
result.push(currentMsg);
i++;
continue;
}
// Collect consecutive tool messages with the same toolName
const toolMessagesGroup = [currentMsg];
let j = i + 1;
while (j < messages.length) {
const nextMsg = messages[j];
if (isToolMessage(nextMsg) && nextMsg.toolName === currentMsg.toolName) {
toolMessagesGroup.push(nextMsg);
j++;
} else {
break;
}
}
// If we have multiple tool messages with the same toolName, collapse them
if (toolMessagesGroup.length > 1) {
// Determine the status to show based on priority rules
const lastMessage = toolMessagesGroup[toolMessagesGroup.length - 1];
let titleSource = lastMessage;
// Check if we have running messages - if so, show the last running one and use its titles
const runningMessages = toolMessagesGroup.filter((msg) => msg.status === 'running');
const errorMessage = toolMessagesGroup.find((msg) => msg.status === 'error');
if (runningMessages.length > 0) {
const lastRunning = runningMessages[runningMessages.length - 1];
titleSource = lastRunning;
} else if (errorMessage) {
titleSource = errorMessage;
}
// Combine all updates from all tool messages
const combinedUpdates = toolMessagesGroup.flatMap((msg) => msg.updates || []);
// Create collapsed message with title logic based on final status
const collapsedMessage: ChatUI.ToolMessage = {
...lastMessage,
status: titleSource.status,
updates: combinedUpdates,
displayTitle: titleSource.displayTitle,
// Only set customDisplayTitle if status is running (for example "Adding X node")
customDisplayTitle:
titleSource.status === 'running' ? titleSource.customDisplayTitle : undefined,
};
result.push(collapsedMessage);
} else {
// Single tool message, add as-is
result.push(currentMsg);
}
i = j;
}
return result;
}
// Ensure all messages have required id and read properties, and collapse tool messages
const normalizedMessages = computed(() => {
const normalized = normalizeMessages(props.messages);
return collapseToolMessages(normalized);
});
const textInputValue = ref<string>('');

View File

@@ -11,7 +11,7 @@ import TextMessage from './TextMessage.vue';
import ToolMessage from './ToolMessage.vue';
import type { ChatUI, RatingFeedback } from '../../../types/assistant';
interface Props {
export interface Props {
message: ChatUI.AssistantMessage;
isFirstOfRole: boolean;
user?: {

View File

@@ -0,0 +1,245 @@
import { mount } from '@vue/test-utils';
import { createPinia, setActivePinia } from 'pinia';
import type { Props } from './ToolMessage.vue';
import ToolMessage from './ToolMessage.vue';
import type { ChatUI } from '../../../types/assistant';
// Mock i18n to return keys instead of translated text
vi.mock('@n8n/design-system/composables/useI18n', () => ({
useI18n: () => ({
t: (key: string) => key,
}),
}));
// Common mount options to reduce duplication
const createMountOptions = (props: Props) => ({
props,
global: {
stubs: {
BaseMessage: {
template: '<div><slot /></div>',
},
N8nIcon: true,
},
},
});
// Helper function to mount ToolMessage with common options
const mountToolMessage = (props: Props) => mount(ToolMessage, createMountOptions(props));
beforeEach(() => {
setActivePinia(createPinia());
});
describe('ToolMessage', () => {
const baseMessage: ChatUI.ToolMessage & { id: string; read: boolean } = {
id: 'test-tool-message',
role: 'assistant',
type: 'tool',
toolName: 'search_files',
status: 'running',
updates: [],
read: false,
};
const user = {
firstName: 'John',
lastName: 'Doe',
};
describe('rendering', () => {
it('should render correctly with basic props', () => {
const wrapper = mountToolMessage({
message: baseMessage,
isFirstOfRole: true,
user,
});
expect(wrapper.find('.toolMessage').exists()).toBe(true);
expect(wrapper.find('.header').exists()).toBe(true);
expect(wrapper.find('.titleRow').exists()).toBe(true);
expect(wrapper.find('.status').exists()).toBe(true);
});
it('should render with custom display title', () => {
const messageWithCustomTitle = {
...baseMessage,
customDisplayTitle: 'Custom Tool Name',
};
const wrapper = mountToolMessage({
message: messageWithCustomTitle,
isFirstOfRole: true,
});
expect(wrapper.text()).toContain('Custom Tool Name');
});
it('should render with display title', () => {
const messageWithDisplayTitle = {
...baseMessage,
displayTitle: 'Display Tool Name',
};
const wrapper = mountToolMessage({
message: messageWithDisplayTitle,
isFirstOfRole: true,
});
expect(wrapper.text()).toContain('Display Tool Name');
});
it('should render tool name in title case when no custom titles', () => {
const messageWithSnakeCase = {
...baseMessage,
toolName: 'search_file_contents',
};
const wrapper = mountToolMessage({
message: messageWithSnakeCase,
isFirstOfRole: true,
});
expect(wrapper.text()).toContain('Search File Contents');
});
});
describe('status handling', () => {
it('should render running status with spinner icon', () => {
const runningMessage = {
...baseMessage,
status: 'running' as const,
};
const wrapper = mountToolMessage({
message: runningMessage,
isFirstOfRole: true,
});
expect(wrapper.html()).toContain('icon="spinner"');
expect(wrapper.html()).toContain('spin');
});
it('should render completed status with check icon', () => {
const completedMessage = {
...baseMessage,
status: 'completed' as const,
};
const wrapper = mountToolMessage({
message: completedMessage,
isFirstOfRole: true,
});
expect(wrapper.html()).toContain('icon="circle-check"');
});
it('should render error status with error icon', () => {
const errorMessage = {
...baseMessage,
status: 'error' as const,
};
const wrapper = mountToolMessage({
message: errorMessage,
isFirstOfRole: true,
});
expect(wrapper.html()).toContain('icon="triangle-alert"');
});
});
describe('tooltip behavior', () => {
it('should enable tooltip for running status', () => {
const runningMessage = {
...baseMessage,
status: 'running' as const,
};
const wrapper = mountToolMessage({
message: runningMessage,
isFirstOfRole: true,
});
// Check that tooltip is enabled by looking for the actual tooltip attributes
expect(wrapper.html()).toContain('icon="spinner"');
});
it('should disable tooltip for non-running status', () => {
const completedMessage = {
...baseMessage,
status: 'completed' as const,
};
const wrapper = mountToolMessage({
message: completedMessage,
isFirstOfRole: true,
});
// Check that the completed icon is rendered instead of spinner
expect(wrapper.html()).toContain('icon="circle-check"');
});
});
describe('toolDisplayName', () => {
it('should prioritize customDisplayTitle', () => {
const message = {
...baseMessage,
customDisplayTitle: 'Custom Title',
displayTitle: 'Display Title',
toolName: 'tool_name',
};
const wrapper = mountToolMessage({
message,
isFirstOfRole: true,
});
expect(wrapper.text()).toContain('Custom Title');
});
it('should use displayTitle when customDisplayTitle is not available', () => {
const message = {
...baseMessage,
displayTitle: 'Display Title',
toolName: 'tool_name',
};
const wrapper = mountToolMessage({
message,
isFirstOfRole: true,
});
expect(wrapper.text()).toContain('Display Title');
});
it('should convert snake_case toolName to Title Case', () => {
const message = {
...baseMessage,
toolName: 'convert_snake_case_to_title',
};
const wrapper = mountToolMessage({
message,
isFirstOfRole: true,
});
expect(wrapper.text()).toContain('Convert Snake Case To Title');
});
it('should handle single word toolName', () => {
const message = {
...baseMessage,
toolName: 'search',
};
const wrapper = mountToolMessage({
message,
isFirstOfRole: true,
});
expect(wrapper.text()).toContain('Search');
});
});
});

View File

@@ -1,14 +1,15 @@
<script setup lang="ts">
import { ref, computed } from 'vue';
import { computed } from 'vue';
import { useI18n } from '@n8n/design-system/composables/useI18n';
import BaseMessage from './BaseMessage.vue';
import type { ChatUI } from '../../../types/assistant';
import N8nIcon from '../../N8nIcon';
import N8nText from '../../N8nText';
import N8nTooltip from '../../N8nTooltip';
interface Props {
export interface Props {
message: ChatUI.ToolMessage & { id: string; read: boolean };
isFirstOfRole: boolean;
showProgressLogs?: boolean;
@@ -21,40 +22,22 @@ interface Props {
const props = defineProps<Props>();
const { t } = useI18n();
const expanded = ref(false);
const toolDisplayName = computed(() => {
// Convert tool names from snake_case to Title Case
return props.message.toolName
.split('_')
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ');
});
const latestInput = computed(() => {
const inputUpdate = props.message.updates?.find((u) => u.type === 'input');
return inputUpdate?.data;
});
const latestOutput = computed(() => {
const outputUpdate = props.message.updates.find((u) => u.type === 'output');
return outputUpdate?.data;
});
const latestError = computed(() => {
const errorUpdate = props.message.updates.find((u) => u.type === 'error');
return errorUpdate?.data;
});
const progressMessages = computed(() => {
return (props.message.updates ?? []).filter((u) => u.type === 'progress').map((u) => u.data);
return (
props.message.customDisplayTitle ??
props.message.displayTitle ??
props.message.toolName
.split('_')
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ')
);
});
const statusMessage = computed(() => {
switch (props.message.status) {
case 'running':
return t('assistantChat.builder.toolRunning');
case 'completed':
return t('assistantChat.builder.toolCompleted');
case 'error':
return t('assistantChat.builder.toolError');
default:
@@ -67,40 +50,20 @@ const statusColor = computed(() => {
case 'completed':
return 'success';
case 'error':
return 'danger';
return 'warning';
default:
return 'secondary';
}
});
function formatJSON(data: Record<string, unknown> | string): string {
if (!data) return '';
try {
return JSON.stringify(data, null, 2);
} catch {
// eslint-disable-next-line @typescript-eslint/no-base-to-string
return String(data);
}
}
function toggleExpanded() {
expanded.value = !expanded.value;
}
</script>
<template>
<BaseMessage :message="message" :is-first-of-role="isFirstOfRole" :user="user">
<div :class="$style.toolMessage">
<div :class="$style.header" @click="toggleExpanded">
<div :class="$style.header">
<div :class="$style.titleRow">
<N8nIcon
:icon="expanded ? 'chevron-down' : 'chevron-right'"
size="small"
:class="$style.expandIcon"
/>
<span :class="$style.toolName">{{ toolDisplayName }}</span>
<div :class="$style.status">
<N8nTooltip placement="left" :disabled="message.status !== 'running'">
<N8nTooltip placement="top" :disabled="!statusMessage">
<template #content>
<span :class="$style.statusText">
{{ statusMessage }}
@@ -111,49 +74,24 @@ function toggleExpanded() {
icon="spinner"
spin
:color="statusColor"
size="large"
/>
<N8nIcon
v-else-if="message.status === 'error'"
icon="status-error"
icon="triangle-alert"
:color="statusColor"
size="large"
/>
<N8nIcon v-else icon="status-completed" :color="statusColor" />
<N8nIcon v-else icon="circle-check" :color="statusColor" size="large" />
</N8nTooltip>
</div>
</div>
</div>
<div v-if="expanded" :class="$style.content">
<!-- Progress messages -->
<div v-if="progressMessages.length > 0 && showProgressLogs" :class="$style.section">
<div :class="$style.sectionTitle">Progress</div>
<div
v-for="(progress, index) in progressMessages"
:key="index"
:class="$style.progressItem"
<N8nText
size="small"
bold
:color="message.status === 'running' ? 'text-light' : 'text-dark'"
:class="{ [$style.running]: message.status === 'running' }"
>{{ toolDisplayName }}</N8nText
>
{{ progress }}
</div>
</div>
<!-- Input -->
<div v-if="latestInput" :class="$style.section">
<div :class="$style.sectionTitle">Input</div>
<pre :class="$style.jsonContent">{{ formatJSON(latestInput) }}</pre>
</div>
<!-- Output -->
<div v-if="latestOutput" :class="$style.section">
<div :class="$style.sectionTitle">Output</div>
<pre :class="$style.jsonContent">{{ formatJSON(latestOutput) }}</pre>
</div>
<!-- Error -->
<div v-if="latestError" :class="$style.section">
<div :class="$style.sectionTitle">Error</div>
<div :class="$style.errorContent">
{{ latestError.message || latestError }}
</div>
</div>
</div>
</div>
@@ -161,45 +99,30 @@ function toggleExpanded() {
</template>
<style lang="scss" module>
@use '../../../css/mixins/animations';
.toolMessage {
width: 100%;
}
.header {
cursor: pointer;
padding: var(--spacing-xs);
border-radius: var(--border-radius-base);
background-color: var(--color-background-light);
&:hover {
background-color: var(--color-background-base);
}
}
.titleRow {
display: flex;
align-items: center;
gap: var(--spacing-xs);
}
.expandIcon {
flex-shrink: 0;
}
.toolName {
font-weight: var(--font-weight-bold);
flex: 1;
}
.status {
display: flex;
align-items: center;
gap: var(--spacing-3xs);
}
.running {
@include animations.shimmer;
}
.statusText {
font-size: var(--font-size-2xs);
text-transform: capitalize;
&.status-running {
color: var(--execution-card-text-waiting);
@@ -215,20 +138,10 @@ function toggleExpanded() {
}
.content {
margin-top: var(--spacing-xs);
padding: var(--spacing-xs);
background-color: var(--color-background-xlight);
padding: 0 var(--spacing-xs) var(--spacing-xs) var(--spacing-xs);
border-radius: var(--border-radius-base);
}
.section {
margin-bottom: var(--spacing-s);
&:last-child {
margin-bottom: 0;
}
}
.sectionTitle {
font-weight: var(--font-weight-bold);
font-size: var(--font-size-2xs);
@@ -236,39 +149,6 @@ function toggleExpanded() {
margin-bottom: var(--spacing-3xs);
}
.progressItem {
font-size: var(--font-size-2xs);
color: var(--color-text-base);
margin-bottom: var(--spacing-3xs);
}
.jsonContent {
font-family: var(--font-family-monospace);
font-size: var(--font-size-3xs);
background-color: var(--color-background-base);
padding: var(--spacing-xs);
border-radius: var(--border-radius-base);
overflow-x: auto;
margin: 0;
max-height: 300px;
overflow-y: auto;
@supports not (selector(::-webkit-scrollbar)) {
scrollbar-width: thin;
}
@supports selector(::-webkit-scrollbar) {
&::-webkit-scrollbar {
width: var(--spacing-2xs);
height: var(--spacing-2xs);
}
&::-webkit-scrollbar-thumb {
border-radius: var(--spacing-xs);
background: var(--color-foreground-dark);
border: var(--spacing-5xs) solid white;
}
}
}
.errorContent {
color: var(--color-danger);
font-size: var(--font-size-2xs);

View File

@@ -8,6 +8,11 @@ describe('AssistantLoadingMessage', () => {
props: {
message: 'Thinking...',
},
global: {
stubs: {
N8nText: true,
},
},
});
expect(container).toMatchSnapshot();
});

View File

@@ -22,6 +22,11 @@ Default.args = {
message: 'Searching n8n documentation for the best possible answer...',
};
export const Thinking = Template.bind({});
Thinking.args = {
message: 'Thinking...',
};
export const NarrowContainer = Template.bind({});
NarrowContainer.args = {
...Default.args,

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import AssistantAvatar from '../AskAssistantAvatar/AssistantAvatar.vue';
import N8nText from '../N8nText';
withDefaults(
defineProps<{
@@ -14,18 +14,19 @@ withDefaults(
<template>
<div :class="$style.container">
<div :class="$style.avatar">
<AssistantAvatar size="mini" />
</div>
<div :class="$style['message-container']">
<transition :name="animationType" mode="out-in">
<span v-if="message" :key="message" :class="$style.message">{{ message }}</span>
<N8nText v-if="message" :key="message" :class="$style.message" :shimmer="true">{{
message
}}</N8nText>
</transition>
</div>
</div>
</template>
<style module lang="scss">
@use '../../css/mixins/animations';
.container {
display: flex;
align-items: center;
@@ -33,12 +34,6 @@ withDefaults(
user-select: none;
}
.avatar {
height: var(--spacing-m);
animation: pulse 1.5s infinite;
position: relative;
}
.message-container {
display: inline-flex;
position: relative;
@@ -54,21 +49,8 @@ withDefaults(
font-size: var(--font-size-2xs);
color: var(--color-text-base);
text-align: left;
}
@keyframes pulse {
0% {
transform: scale(1);
opacity: 0.7;
}
50% {
transform: scale(1.2);
opacity: 1;
}
100% {
transform: scale(1);
opacity: 0.7;
}
@include animations.shimmer;
}
</style>

View File

@@ -6,50 +6,6 @@ exports[`AssistantLoadingMessage > renders loading message correctly 1`] = `
class="container"
data-v-4e90e01e=""
>
<div
class="avatar"
data-v-4e90e01e=""
>
<div
class="container mini"
data-v-4e90e01e=""
>
<svg
fill="none"
height="8"
viewBox="0 0 24 24"
width="8"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M19.9658 14.0171C19.9679 14.3549 19.8654 14.6851 19.6722 14.9622C19.479 15.2393 19.2046 15.4497 18.8869 15.5645L13.5109 17.5451L11.5303 22.9211C11.4137 23.2376 11.2028 23.5107 10.9261 23.7037C10.6494 23.8966 10.3202 24 9.9829 24C9.64559 24 9.3164 23.8966 9.0397 23.7037C8.76301 23.5107 8.55212 23.2376 8.43549 22.9211L6.45487 17.5451L1.07888 15.5645C0.762384 15.4479 0.489262 15.237 0.296347 14.9603C0.103431 14.6836 0 14.3544 0 14.0171C0 13.6798 0.103431 13.3506 0.296347 13.0739C0.489262 12.7972 0.762384 12.5863 1.07888 12.4697L6.45487 10.4891L8.43549 5.11309C8.55212 4.79659 8.76301 4.52347 9.0397 4.33055C9.3164 4.13764 9.64559 4.0342 9.9829 4.0342C10.3202 4.0342 10.6494 4.13764 10.9261 4.33055C11.2028 4.52347 11.4137 4.79659 11.5303 5.11309L13.5109 10.4891L18.8869 12.4697C19.2046 12.5845 19.479 12.7949 19.6722 13.072C19.8654 13.3491 19.9679 13.6793 19.9658 14.0171ZM14.1056 4.12268H15.7546V5.77175C15.7546 5.99043 15.8415 6.20015 15.9961 6.35478C16.1508 6.50941 16.3605 6.59628 16.5792 6.59628C16.7979 6.59628 17.0076 6.50941 17.1622 6.35478C17.3168 6.20015 17.4037 5.99043 17.4037 5.77175V4.12268H19.0528C19.2715 4.12268 19.4812 4.03581 19.6358 3.88118C19.7905 3.72655 19.8773 3.51682 19.8773 3.29814C19.8773 3.07946 19.7905 2.86974 19.6358 2.71511C19.4812 2.56048 19.2715 2.47361 19.0528 2.47361H17.4037V0.824535C17.4037 0.605855 17.3168 0.396131 17.1622 0.241501C17.0076 0.0868704 16.7979 0 16.5792 0C16.3605 0 16.1508 0.0868704 15.9961 0.241501C15.8415 0.396131 15.7546 0.605855 15.7546 0.824535V2.47361H14.1056C13.8869 2.47361 13.6772 2.56048 13.5225 2.71511C13.3679 2.86974 13.281 3.07946 13.281 3.29814C13.281 3.51682 13.3679 3.72655 13.5225 3.88118C13.6772 4.03581 13.8869 4.12268 14.1056 4.12268ZM23.1755 7.42082H22.3509V6.59628C22.3509 6.3776 22.2641 6.16788 22.1094 6.01325C21.9548 5.85862 21.7451 5.77175 21.5264 5.77175C21.3077 5.77175 21.098 5.85862 20.9434 6.01325C20.7887 6.16788 20.7019 6.3776 20.7019 6.59628V7.42082H19.8773C19.6586 7.42082 19.4489 7.50769 19.2943 7.66232C19.1397 7.81695 19.0528 8.02667 19.0528 8.24535C19.0528 8.46404 19.1397 8.67376 19.2943 8.82839C19.4489 8.98302 19.6586 9.06989 19.8773 9.06989H20.7019V9.89443C20.7019 10.1131 20.7887 10.3228 20.9434 10.4775C21.098 10.6321 21.3077 10.719 21.5264 10.719C21.7451 10.719 21.9548 10.6321 22.1094 10.4775C22.2641 10.3228 22.3509 10.1131 22.3509 9.89443V9.06989H23.1755C23.3941 9.06989 23.6039 8.98302 23.7585 8.82839C23.9131 8.67376 24 8.46404 24 8.24535C24 8.02667 23.9131 7.81695 23.7585 7.66232C23.6039 7.50769 23.3941 7.42082 23.1755 7.42082Z"
fill="white"
/>
<defs>
<lineargradient
gradientUnits="userSpaceOnUse"
id="paint0_linear_173_12825"
x1="-3.67094e-07"
x2="28.8315"
y1="-0.000120994"
y2="9.82667"
>
<stop
stop-color="var(--color-assistant-highlight-1)"
/>
<stop
offset="0.495"
stop-color="var(--color-assistant-highlight-2)"
/>
<stop
offset="1"
stop-color="var(--color-assistant-highlight-3)"
/>
</lineargradient>
</defs>
</svg>
</div>
</div>
<div
class="message-container"
data-v-4e90e01e=""
@@ -62,12 +18,15 @@ exports[`AssistantLoadingMessage > renders loading message correctly 1`] = `
name="slide-vertical"
persisted="false"
>
<span
<n8n-text-stub
bold="false"
class="message"
compact="false"
data-v-4e90e01e=""
>
Thinking...
</span>
shimmer="true"
size="medium"
tag="span"
/>
</transition-stub>
</div>
</div>

View File

@@ -0,0 +1,16 @@
@mixin shimmer {
background: linear-gradient(135deg, #fff, #5e5e5e, #fff);
background-clip: text;
color: transparent;
background-size: 200% 100%;
animation: shimmer 2.5s linear infinite;
}
@keyframes shimmer {
0% {
background-position: 200% 0;
}
100% {
background-position: -200% 0;
}
}

View File

@@ -49,9 +49,8 @@ export default {
'assistantChat.builder.selectedNodes': 'Selected workflow nodes',
'assistantChat.builder.selectingNodes': 'Selecting nodes...',
'assistantChat.builder.generatedNodes': 'Generated workflow nodes',
'assistantChat.builder.toolRunning': 'Tool running',
'assistantChat.builder.toolCompleted': 'Tool completed',
'assistantChat.builder.toolError': 'Tool completed with error',
'assistantChat.builder.toolRunning': 'Tool still running',
'assistantChat.builder.toolError': 'Some tool calls have failed. Agent will retry these.',
'assistantChat.errorParsingMarkdown': 'Error parsing markdown content',
'assistantChat.aiAssistantLabel': 'AI Assistant',
'assistantChat.aiAssistantName': 'Assistant',

View File

@@ -77,6 +77,8 @@ export namespace ChatUI {
type: 'tool';
toolName: string;
toolCallId?: string;
displayTitle?: string; // tool display name like "Searching for node"
customDisplayTitle?: string; // tool call specific custom title like "Searching for OpenAI"
status: 'running' | 'completed' | 'error';
updates: Array<{
type: 'input' | 'output' | 'progress' | 'error';