feat: Add metering UI (no-changelog) (#20021)

Co-authored-by: Michael Drury <michael.drury@n8n.io>
This commit is contained in:
Mutasem Aldmour 2025-09-26 15:01:56 +02:00 committed by GitHub
parent 3963e97c1f
commit 249d8f6ee6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
24 changed files with 1966 additions and 2259 deletions

View File

@ -7,7 +7,61 @@ import { n8nHtml } from '../../directives';
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'];
// Mock useI18n
vi.mock('../../composables/useI18n', () => ({
useI18n: () => ({
t: (key: string) => key,
}),
}));
// Mock getSupportedMessageComponent helper
vi.mock('./messages/helpers', () => ({
getSupportedMessageComponent: vi.fn((type: string) => {
const supportedTypes = ['text', 'code-diff', 'block', 'tool', 'error', 'event'];
return supportedTypes.includes(type) ? 'MockedComponent' : null;
}),
}));
// Mock isToolMessage type guard
vi.mock('../../types/assistant', async (importOriginal) => {
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
const original = await importOriginal<typeof import('../../types/assistant')>();
return {
...original,
isToolMessage: vi.fn((message: ChatUI.AssistantMessage) => {
return (
typeof message === 'object' &&
message !== null &&
'type' in message &&
message.type === 'tool'
);
}),
};
});
const stubs = [
'n8n-avatar',
'n8n-button',
'n8n-icon',
'n8n-icon-button',
'n8n-prompt-input',
'AssistantIcon',
'AssistantText',
'InlineAskAssistantButton',
'AssistantLoadingMessage',
];
// Stub MessageWrapper to render message as stringified JSON
const MessageWrapperStub = {
name: 'MessageWrapper',
props: ['message'],
template: '<div data-test-id="message-wrapper-stub">{{ JSON.stringify(message) }}</div>',
};
const stubsWithMessageWrapper = {
...Object.fromEntries(stubs.map((stub) => [stub, true])),
MessageWrapper: MessageWrapperStub,
};
describe('AskAssistantChat', () => {
it('renders default placeholder chat correctly', () => {
@ -15,7 +69,7 @@ describe('AskAssistantChat', () => {
props: {
user: { firstName: 'Kobi', lastName: 'Dog' },
},
global: { stubs },
global: { stubs: stubsWithMessageWrapper },
});
expect(container).toMatchSnapshot();
});
@ -26,7 +80,7 @@ describe('AskAssistantChat', () => {
directives: {
n8nHtml,
},
stubs,
stubs: stubsWithMessageWrapper,
},
props: {
user: { firstName: 'Kobi', lastName: 'Dog' },
@ -107,7 +161,7 @@ describe('AskAssistantChat', () => {
directives: {
n8nHtml,
},
stubs,
stubs: stubsWithMessageWrapper,
},
props: {
user: { firstName: 'Kobi', lastName: 'Dog' },
@ -133,7 +187,7 @@ describe('AskAssistantChat', () => {
directives: {
n8nHtml,
},
stubs,
stubs: stubsWithMessageWrapper,
},
props: {
user: { firstName: 'Kobi', lastName: 'Dog' },
@ -165,7 +219,7 @@ describe('AskAssistantChat', () => {
directives: {
n8nHtml,
},
stubs,
stubs: stubsWithMessageWrapper,
},
props: {
user: { firstName: 'Kobi', lastName: 'Dog' },
@ -192,7 +246,7 @@ describe('AskAssistantChat', () => {
directives: {
n8nHtml,
},
stubs,
stubs: stubsWithMessageWrapper,
},
props: {
user: { firstName: 'Kobi', lastName: 'Dog' },
@ -210,34 +264,9 @@ describe('AskAssistantChat', () => {
},
});
expect(wrapper.container).toMatchSnapshot();
expect(wrapper.getByTestId('error-retry-button')).toBeInTheDocument();
});
it('does not render retry button if no error is present', () => {
const wrapper = render(AskAssistantChat, {
global: {
directives: {
n8nHtml,
},
stubs,
},
props: {
user: { firstName: 'Kobi', lastName: 'Dog' },
messages: [
{
id: '1',
type: 'text',
role: 'assistant',
content:
'Hi Max! Here is my top solution to fix the error in your **Transform data** node👇',
read: false,
},
],
},
});
expect(wrapper.container).toMatchSnapshot();
expect(wrapper.queryByTestId('error-retry-button')).not.toBeInTheDocument();
// Since MessageWrapper is stubbed, we can't test for the error retry button directly
// We just verify the error message is rendered
expect(wrapper.container.textContent).toContain('This is an error message.');
});
it('limits maximum input length when maxCharacterLength prop is specified', async () => {
@ -246,7 +275,7 @@ describe('AskAssistantChat', () => {
directives: {
n8nHtml,
},
stubs,
stubs: stubsWithMessageWrapper,
},
props: {
user: { firstName: 'Kobi', lastName: 'Dog' },
@ -264,7 +293,7 @@ describe('AskAssistantChat', () => {
const MessageWrapperMock = vi.fn(() => ({
template: '<div data-testid="message-wrapper-mock"></div>',
}));
const stubsWithMessageWrapper = {
const stubsWithCustomMessageWrapper = {
...Object.fromEntries(stubs.map((stub) => [stub, true])),
MessageWrapper: MessageWrapperMock,
};
@ -285,7 +314,7 @@ describe('AskAssistantChat', () => {
const renderWithMessages = (messages: ChatUI.AssistantMessage[], extraProps = {}) => {
MessageWrapperMock.mockClear();
return render(AskAssistantChat, {
global: { stubs: stubsWithMessageWrapper },
global: { stubs: stubsWithCustomMessageWrapper },
props: {
user: { firstName: 'Kobi', lastName: 'Dog' },
messages,
@ -299,7 +328,7 @@ describe('AskAssistantChat', () => {
return render(AskAssistantChat, {
global: {
directives: { n8nHtml },
stubs: stubsWithMessageWrapper,
stubs: stubsWithCustomMessageWrapper,
},
props: {
user: { firstName: 'Kobi', lastName: 'Dog' },
@ -815,6 +844,7 @@ describe('AskAssistantChat', () => {
directives: { n8nHtml },
stubs: {
...Object.fromEntries(stubs.map((stub) => [stub, true])),
MessageWrapper: MessageWrapperStub,
'n8n-button': { template: '<button><slot></button' },
},
},
@ -855,8 +885,8 @@ describe('AskAssistantChat', () => {
// 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');
// Quick reply title should be visible (checking for i18n key since we're mocking i18n)
expect(wrapper.container.textContent).toContain('assistantChat.quickRepliesTitle');
expect(wrapper.container).toHaveTextContent('Give me another solution');
expect(wrapper.container).toHaveTextContent('All good');
});
@ -889,8 +919,8 @@ describe('AskAssistantChat', () => {
// 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');
// Quick reply title should be visible (checking for i18n key since we're mocking i18n)
expect(wrapper.container.textContent).toContain('assistantChat.quickRepliesTitle');
expect(wrapper.container).toHaveTextContent('Accept suggestion');
expect(wrapper.container).toHaveTextContent('Reject suggestion');
@ -913,8 +943,11 @@ describe('AskAssistantChat', () => {
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');
expect(wrapper.container.textContent).not.toContain('assistantChat.quickRepliesTitle');
// The message with quick replies should be in the JSON but not rendered as buttons
const messageWrapperStub = wrapper.getByTestId('message-wrapper-stub');
expect(messageWrapperStub.textContent).toContain('Give me another solution');
expect(messageWrapperStub.textContent).toContain('"quickReplies"');
});
it('should not render quick replies for non-last messages', () => {
@ -942,8 +975,12 @@ describe('AskAssistantChat', () => {
// 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');
expect(wrapper.container.textContent).not.toContain('assistantChat.quickRepliesTitle');
// The messages with quick replies should be in the JSON but not rendered as buttons
const messageWrapperStubs = wrapper.getAllByTestId('message-wrapper-stub');
expect(messageWrapperStubs[0].textContent).toContain('Give me another solution');
expect(messageWrapperStubs[0].textContent).toContain('"quickReplies"');
expect(messageWrapperStubs[1].textContent).toContain('Follow up message');
});
it('should not render quick replies when last message has no quickReplies', () => {
@ -960,7 +997,7 @@ describe('AskAssistantChat', () => {
const wrapper = renderWithQuickReplies(messages);
expect(wrapper.queryAllByTestId('quick-replies')).toHaveLength(0);
expect(wrapper.container.textContent).not.toContain('Quick reply');
expect(wrapper.container.textContent).not.toContain('assistantChat.quickRepliesTitle');
});
it('should not render quick replies when last message has empty quickReplies array', () => {
@ -980,7 +1017,7 @@ describe('AskAssistantChat', () => {
const wrapper = renderWithQuickReplies(messages);
expect(wrapper.queryAllByTestId('quick-replies')).toHaveLength(0);
expect(wrapper.container.textContent).not.toContain('Quick reply');
expect(wrapper.container.textContent).not.toContain('assistantChat.quickRepliesTitle');
});
});
@ -990,19 +1027,26 @@ describe('AskAssistantChat', () => {
global: {
directives: { n8nHtml },
stubs: {
...Object.fromEntries(stubs.map((stub) => [stub, true])),
N8nPromptInput: {
name: 'N8nPromptInput',
...Object.fromEntries(
stubs.filter((stub) => stub !== 'n8n-prompt-input').map((stub) => [stub, true]),
),
'message-wrapper': MessageWrapperStub,
'n8n-prompt-input': {
name: 'n8n-prompt-input',
props: [
'modelValue',
'inputPlaceholder',
'placeholder',
'disabled',
'streaming',
'maxCharacterLength',
'maxLength',
'creditsQuota',
'creditsRemaining',
'showAskOwnerTooltip',
'refocusAfterSend',
],
emits: ['update:modelValue', 'submit', 'stop'],
emits: ['update:modelValue', 'submit', 'stop', 'upgrade-click'],
setup(
_: unknown,
props: unknown,
{
emit,
expose,
@ -1016,6 +1060,7 @@ describe('AskAssistantChat', () => {
expose({ focusInput });
return {
props,
handleSubmit: () => emit('submit'),
updateValue: (e: Event) => {
const target = e.target as HTMLTextAreaElement;
@ -1038,19 +1083,22 @@ describe('AskAssistantChat', () => {
});
const textarea = wrapper.find('[data-test-id="chat-input"] textarea');
await textarea.setValue('Test message');
expect(textarea.exists()).toBe(true);
await textarea.trigger('input');
await textarea.setValue('Test message');
await wrapper.vm.$nextTick();
const sendButton = wrapper.find('[data-test-id="chat-input"] button');
expect(sendButton.exists()).toBe(true);
await sendButton.trigger('click');
await wrapper.vm.$nextTick();
// Verify message was emitted with the correct value
expect(wrapper.emitted('message')).toBeTruthy();
const messageEvents = wrapper.emitted('message');
expect(messageEvents?.[0]).toEqual(['Test message']);
await wrapper.vm.$nextTick();
wrapper.unmount();
});
});

View File

@ -30,6 +30,9 @@ interface Props {
inputPlaceholder?: string;
scrollOnNewMessage?: boolean;
showStop?: boolean;
creditsQuota?: number;
creditsRemaining?: number;
showAskOwnerTooltip?: boolean;
maxCharacterLength?: number;
}
@ -40,6 +43,7 @@ const emit = defineEmits<{
codeReplace: [number];
codeUndo: [number];
feedback: [RatingFeedback];
'upgrade-click': [];
}>();
const onClose = () => emit('close');
@ -422,8 +426,12 @@ defineExpose({
:placeholder="inputPlaceholder || t('assistantChat.inputPlaceholder')"
:disabled="sessionEnded || disabled"
:streaming="streaming"
:credits-quota="creditsQuota"
:credits-remaining="creditsRemaining"
:show-ask-owner-tooltip="showAskOwnerTooltip"
:max-length="maxCharacterLength"
:refocus-after-send="true"
@upgrade-click="emit('upgrade-click')"
data-test-id="chat-input"
@submit="onSendMessage"
@stop="emit('stop')"

View File

@ -1,5 +1,6 @@
import type { StoryFn } from '@storybook/vue3-vite';
import { action } from 'storybook/actions';
import { ref } from 'vue';
import N8nPromptInput from './N8nPromptInput.vue';
@ -39,6 +40,7 @@ export default {
const methods = {
onUpdateModelValue: action('update:modelValue'),
onUpgradeClick: action('upgrade-click'),
onSubmit: action('submit'),
onStop: action('stop'),
onFocus: action('focus'),
@ -280,3 +282,98 @@ const MultipleInstancesTemplate: StoryFn = (args, { argTypes }) => ({
export const DifferentSizes = MultipleInstancesTemplate.bind({});
DifferentSizes.args = {};
// Credit Tracking Stories
export const WithCreditsAndUpgrade: StoryFn = Template.bind({});
WithCreditsAndUpgrade.args = {
placeholder: 'Type your message here...',
creditsQuota: 150,
creditsRemaining: 119,
};
WithCreditsAndUpgrade.storyName = 'With Credits and Upgrade Button';
export const WithCreditsNoUpgrade: StoryFn = Template.bind({});
WithCreditsNoUpgrade.args = {
placeholder: 'Type your message here...',
creditsQuota: 150,
creditsRemaining: 23,
showAskOwnerTooltip: true,
};
WithCreditsNoUpgrade.storyName = 'With Credits (Shows Ask Admin Tooltip)';
export const LowCredits: StoryFn = Template.bind({});
LowCredits.args = {
placeholder: 'Type your message here...',
creditsQuota: 150,
creditsRemaining: 5,
};
LowCredits.storyName = 'Low Credits Remaining';
export const NoCreditsRemaining: StoryFn = Template.bind({});
NoCreditsRemaining.args = {
placeholder: 'Type your message here...',
creditsQuota: 150,
creditsRemaining: 0,
};
NoCreditsRemaining.storyName = 'No Credits Remaining';
const CreditsInteractiveTemplate: StoryFn = (args) => ({
components: { N8nPromptInput },
setup() {
const inputValue = ref('');
const creditsRemaining = ref(args.creditsRemaining || 150);
const creditsQuota = ref(args.creditsQuota || 150);
const handleSubmit = () => {
if (inputValue.value.trim() && creditsRemaining.value > 0) {
creditsRemaining.value--;
inputValue.value = '';
}
action('submit')();
};
return {
args,
inputValue,
creditsRemaining,
creditsQuota,
handleSubmit,
onStop: methods.onStop,
onFocus: methods.onFocus,
onBlur: methods.onBlur,
onUpgradeClick: methods.onUpgradeClick,
};
},
template: `
<div style="max-width: 600px; margin: 20px;">
<div style="margin-bottom: 20px; padding: 20px; background: var(--color-background-base); border-radius: var(--border-radius-base);">
<h3 style="color: var(--color-text-dark); margin-bottom: 10px;">Credits Tracking Demo</h3>
<p style="color: var(--color-text-base); margin-bottom: 10px;">
Each message consumes 1 credit. Credits renew at the beginning of next month.
</p>
<p style="color: var(--color-text-light); font-size: var(--font-size-s);">
Credits remaining: {{ creditsRemaining }} / {{ creditsQuota }}
</p>
</div>
<n8n-prompt-input
v-bind="args"
v-model="inputValue"
:credits-quota="creditsQuota"
:credits-remaining="creditsRemaining"
@submit="handleSubmit"
@stop="onStop"
@focus="onFocus"
@blur="onBlur"
@upgrade-click="onUpgradeClick"
/>
</div>
`,
});
export const CreditsInteractive: StoryFn = CreditsInteractiveTemplate.bind({});
CreditsInteractive.args = {
placeholder: 'Type a message (uses 1 credit)...',
creditsQuota: 150,
creditsRemaining: 2,
};
CreditsInteractive.storyName = 'Credits Interactive Demo';

View File

@ -1,6 +1,8 @@
/* eslint-disable n8n-local-rules/no-interpolation-in-regular-string */
import userEvent from '@testing-library/user-event';
import { fireEvent } from '@testing-library/vue';
import { mount } from '@vue/test-utils';
import { vi } from 'vitest';
import { createComponentRenderer } from '@n8n/design-system/__tests__/render';
@ -476,39 +478,459 @@ describe('N8nPromptInput', () => {
});
});
describe('edge cases', () => {
it('should handle very long single line text', () => {
const longText = 'a'.repeat(500);
describe('credits bar', () => {
it('should hide credit bar when quota is -1', () => {
const { container } = renderComponent({
props: {
modelValue: longText,
maxLength: 1000,
creditsQuota: -1,
creditsRemaining: 0,
},
global: {
stubs: ['N8nCallout', 'N8nScrollArea', 'N8nSendStopButton'],
stubs: [
'N8nCallout',
'N8nScrollArea',
'N8nSendStopButton',
'N8nTooltip',
'N8nLink',
'N8nIcon',
],
},
});
const textarea = container.querySelector('textarea');
expect(textarea).toHaveValue(longText);
// Credit bar should not be rendered
const creditsBar = container.querySelector('.creditsBar');
expect(creditsBar).toBeFalsy();
});
it('should apply maxLinesBeforeScroll prop', () => {
// This prop affects the computed textAreaMaxHeight value
// which is used in multiline mode. We can test that the prop is accepted.
it('should show credit bar when quota is a valid positive number', () => {
const { container } = renderComponent({
props: {
modelValue: 'Test',
maxLinesBeforeScroll: 2,
creditsQuota: 100,
creditsRemaining: 80,
},
global: {
stubs: [
'N8nCallout',
'N8nScrollArea',
'N8nSendStopButton',
'N8nTooltip',
'N8nLink',
'N8nIcon',
],
},
});
// Credit bar should be rendered
const creditsBar = container.querySelector('.creditsBar');
expect(creditsBar).toBeTruthy();
});
it('should show credits bar when creditsQuota and creditsRemaining are provided', () => {
const { container } = renderComponent({
props: {
creditsQuota: 100,
creditsRemaining: 75,
},
global: {
stubs: [
'N8nCallout',
'N8nScrollArea',
'N8nSendStopButton',
'N8nTooltip',
'N8nLink',
'N8nIcon',
],
},
});
const creditsBar = container.querySelector('.creditsBar');
expect(creditsBar).toBeTruthy();
});
it('should show no credits warning when creditsRemaining is 0', () => {
const { container } = renderComponent({
props: {
creditsQuota: 100,
creditsRemaining: 0,
},
global: {
stubs: [
'N8nCallout',
'N8nScrollArea',
'N8nSendStopButton',
'N8nTooltip',
'N8nLink',
'N8nIcon',
],
},
});
const noCredits = container.querySelector('.noCredits');
expect(noCredits).toBeTruthy();
});
it('should disable textarea and send button when no credits remain', () => {
const { container } = renderComponent({
props: {
modelValue: 'Test message',
creditsQuota: 100,
creditsRemaining: 0,
},
global: {
stubs: {
N8nCallout: true,
N8nScrollArea: true,
N8nSendStopButton: {
props: ['disabled', 'streaming'],
template: '<button :disabled="disabled"></button>',
},
N8nTooltip: true,
N8nLink: true,
N8nIcon: true,
},
},
});
const textarea = container.querySelector('textarea');
expect(textarea).toHaveAttribute('disabled');
const button = container.querySelector('button');
expect(button).toHaveAttribute('disabled');
const disabledContainer = container.querySelector('.disabled');
expect(disabledContainer).toBeTruthy();
});
it('should not show placeholder when no credits remain', () => {
const { container } = renderComponent({
props: {
placeholder: 'Type your message...',
creditsQuota: 100,
creditsRemaining: 0,
},
global: {
stubs: [
'N8nCallout',
'N8nScrollArea',
'N8nSendStopButton',
'N8nTooltip',
'N8nLink',
'N8nIcon',
],
},
});
const textarea = container.querySelector('textarea');
expect(textarea).toHaveAttribute('placeholder', '');
});
});
describe('upgrade-click event', () => {
it('should emit upgrade-click event when upgrade link is clicked', async () => {
const wrapper = mount(N8nPromptInput, {
props: {
creditsQuota: 100,
creditsRemaining: 10,
},
global: {
stubs: {
N8nCallout: true,
N8nScrollArea: true,
N8nSendStopButton: true,
N8nTooltip: {
template: '<n8n-tooltip-stub><slot></slot></n8n-tooltip-stub>',
},
N8nLink: true,
N8nIcon: true,
},
},
});
// Find and click the upgrade link
const upgradeLink = wrapper.find('n8n-link-stub');
await upgradeLink.trigger('click');
// Verify the upgrade-click event was emitted
expect(wrapper.emitted('upgrade-click')).toBeTruthy();
expect(wrapper.emitted('upgrade-click')).toHaveLength(1);
wrapper.unmount();
});
});
describe('showAskOwnerTooltip prop', () => {
it('should enable tooltip when showAskOwnerTooltip is true', () => {
const wrapper = mount(N8nPromptInput, {
props: {
creditsQuota: 100,
creditsRemaining: 10,
showAskOwnerTooltip: true,
},
global: {
stubs: {
N8nCallout: true,
N8nScrollArea: true,
N8nSendStopButton: true,
N8nTooltip: {
props: ['disabled', 'content', 'placement'],
template:
'<div :class="`tooltip-${placement || \'top\'}`" :data-disabled="disabled"><slot /></div>',
},
N8nLink: true,
N8nIcon: true,
},
},
});
// Find tooltips with different placements
const creditsTooltip = wrapper.find('.tooltip-top');
const askOwnerTooltip = wrapper.findAll('.tooltip-top')[1]; // Second tooltip with top placement
expect(creditsTooltip.exists()).toBe(true);
expect(askOwnerTooltip.exists()).toBe(true);
expect(askOwnerTooltip.attributes('data-disabled')).toBe('false');
wrapper.unmount();
});
it('should disable tooltip when showAskOwnerTooltip is false', () => {
const wrapper = mount(N8nPromptInput, {
props: {
creditsQuota: 100,
creditsRemaining: 10,
showAskOwnerTooltip: false,
},
global: {
stubs: {
N8nCallout: true,
N8nScrollArea: true,
N8nSendStopButton: true,
N8nTooltip: {
props: ['disabled', 'content', 'placement'],
template:
'<div :class="`tooltip-${placement || \'top\'}`" :data-disabled="disabled"><slot /></div>',
},
N8nLink: true,
N8nIcon: true,
},
},
});
// Find tooltips with different placements
const creditsTooltip = wrapper.find('.tooltip-top');
const askOwnerTooltip = wrapper.findAll('.tooltip-top')[1]; // Second tooltip with top placement
expect(creditsTooltip.exists()).toBe(true);
expect(askOwnerTooltip.exists()).toBe(true);
expect(askOwnerTooltip.attributes('data-disabled')).toBe('true');
wrapper.unmount();
});
});
describe('minLines prop', () => {
it('should start in multiline mode when minLines > 1', () => {
const { container } = renderComponent({
props: {
minLines: 3,
},
global: {
stubs: ['N8nCallout', 'N8nScrollArea', 'N8nSendStopButton'],
},
});
// Should be in multiline mode from the start
expect(container.querySelector('.multilineTextarea')).toBeTruthy();
expect(container.querySelector('.singleLineWrapper')).toBeFalsy();
});
it('should maintain minimum height based on minLines', () => {
const minLines = 3;
const expectedMinHeight = minLines * 18; // 18px per line
const { container } = renderComponent({
props: {
minLines,
modelValue: '', // Empty value
},
global: {
stubs: ['N8nCallout', 'N8nScrollArea', 'N8nSendStopButton'],
},
});
// Component should render without errors
const textarea = container.querySelector('textarea');
expect(textarea).toBeTruthy();
// Check that the textarea has the minimum height
const style = textarea?.getAttribute('style');
expect(style).toContain(`height: ${expectedMinHeight}px`);
});
it('should not go below minLines height when text is deleted', async () => {
const minLines = 2;
const expectedMinHeight = minLines * 18;
const render = renderComponent({
props: {
minLines,
modelValue: 'Line 1\nLine 2\nLine 3',
},
global: {
stubs: ['N8nCallout', 'N8nScrollArea', 'N8nSendStopButton'],
},
});
// Clear the text
await render.rerender({ modelValue: '' });
const textarea = render.container.querySelector('textarea');
const style = textarea?.getAttribute('style');
expect(style).toContain(`height: ${expectedMinHeight}px`);
});
});
describe('refocusAfterSend prop', () => {
it('should refocus textarea after submit when refocusAfterSend is true', async () => {
const wrapper = mount(N8nPromptInput, {
props: {
modelValue: 'Test message',
refocusAfterSend: true,
},
global: {
stubs: ['N8nCallout', 'N8nScrollArea', 'N8nSendStopButton'],
},
attachTo: document.body,
});
const textarea = wrapper.find('textarea').element as HTMLTextAreaElement;
const focusSpy = vi.spyOn(textarea, 'focus');
// Trigger submit
await fireEvent.keyDown(textarea, { key: 'Enter' });
// Wait for next tick and animation frame
await wrapper.vm.$nextTick();
await new Promise(requestAnimationFrame);
expect(focusSpy).toHaveBeenCalled();
wrapper.unmount();
});
it('should not refocus textarea after submit when refocusAfterSend is false', async () => {
const wrapper = mount(N8nPromptInput, {
props: {
modelValue: 'Test message',
refocusAfterSend: false,
},
global: {
stubs: ['N8nCallout', 'N8nScrollArea', 'N8nSendStopButton'],
},
});
const textarea = wrapper.find('textarea').element as HTMLTextAreaElement;
const focusSpy = vi.spyOn(textarea, 'focus');
// Trigger submit
await fireEvent.keyDown(textarea, { key: 'Enter' });
// Wait for next tick
await wrapper.vm.$nextTick();
expect(focusSpy).not.toHaveBeenCalled();
wrapper.unmount();
});
it('should refocus textarea after stop when refocusAfterSend is true', async () => {
const wrapper = mount(N8nPromptInput, {
props: {
modelValue: 'Test message',
streaming: true,
refocusAfterSend: true,
},
global: {
stubs: {
N8nCallout: true,
N8nScrollArea: true,
N8nSendStopButton: {
props: ['disabled', 'streaming'],
template: '<button @click="$emit(\'stop\')">Stop</button>',
emits: ['stop'],
},
},
},
attachTo: document.body,
});
const textarea = wrapper.find('textarea').element as HTMLTextAreaElement;
const focusSpy = vi.spyOn(textarea, 'focus');
// Trigger stop
const stopButton = wrapper.find('button');
await stopButton.trigger('click');
// Wait for next tick and animation frame
await wrapper.vm.$nextTick();
await new Promise(requestAnimationFrame);
expect(focusSpy).toHaveBeenCalled();
wrapper.unmount();
});
});
describe('disabled state', () => {
it('should disable textarea and button when disabled prop is true', () => {
const { container } = renderComponent({
props: {
modelValue: 'Test message',
disabled: true,
},
global: {
stubs: {
N8nCallout: true,
N8nScrollArea: true,
N8nSendStopButton: {
props: ['disabled', 'streaming'],
template: '<button :disabled="disabled"></button>',
},
},
},
});
const textarea = container.querySelector('textarea');
expect(textarea).toHaveAttribute('disabled');
const button = container.querySelector('button');
expect(button).toHaveAttribute('disabled');
const disabledContainer = container.querySelector('.disabled');
expect(disabledContainer).toBeTruthy();
});
it('should not emit submit when disabled', async () => {
const render = renderComponent({
props: {
modelValue: 'Test message',
disabled: true,
},
global: {
stubs: ['N8nCallout', 'N8nScrollArea', 'N8nSendStopButton'],
},
});
const textarea = render.container.querySelector('textarea') as HTMLTextAreaElement;
await fireEvent.keyDown(textarea, { key: 'Enter' });
expect(render.emitted('submit')).toBeFalsy();
});
it('should apply disabled styles to container', () => {
const { container } = renderComponent({
props: {
disabled: true,
},
global: {
stubs: ['N8nCallout', 'N8nScrollArea', 'N8nSendStopButton'],
},
});
const disabledContainer = container.querySelector('.disabled');
expect(disabledContainer).toBeTruthy();
});
});
});

View File

@ -4,8 +4,11 @@ import { computed, nextTick, onMounted, ref, toRef, watch } from 'vue';
import { useCharacterLimit } from '../../composables/useCharacterLimit';
import { useI18n } from '../../composables/useI18n';
import N8nCallout from '../N8nCallout/Callout.vue';
import N8nIcon from '../N8nIcon/Icon.vue';
import N8nLink from '../N8nLink';
import N8nScrollArea from '../N8nScrollArea/N8nScrollArea.vue';
import N8nSendStopButton from '../N8nSendStopButton';
import N8nTooltip from '../N8nTooltip/Tooltip.vue';
export interface N8nPromptInputProps {
modelValue?: string;
@ -15,9 +18,14 @@ export interface N8nPromptInputProps {
minLines?: number;
streaming?: boolean;
disabled?: boolean;
creditsQuota?: number;
creditsRemaining?: number;
showAskOwnerTooltip?: boolean;
refocusAfterSend?: boolean;
}
const INFINITE_CREDITS = -1;
const props = withDefaults(defineProps<N8nPromptInputProps>(), {
modelValue: '',
placeholder: '',
@ -26,6 +34,9 @@ const props = withDefaults(defineProps<N8nPromptInputProps>(), {
minLines: 1,
streaming: false,
disabled: false,
creditsQuota: undefined,
creditsRemaining: undefined,
showAskOwnerTooltip: false,
refocusAfterSend: false,
});
@ -35,6 +46,7 @@ const emit = defineEmits<{
stop: [];
focus: [event: FocusEvent];
blur: [event: FocusEvent];
'upgrade-click': [];
}>();
const { t } = useI18n();
@ -61,13 +73,64 @@ const { characterCount, isOverLimit, isAtLimit } = useCharacterLimit({
const showWarningBanner = computed(() => isAtLimit.value);
const sendDisabled = computed(
() => !textValue.value.trim() || props.streaming || props.disabled || isOverLimit.value,
() =>
!textValue.value.trim() ||
props.streaming ||
props.disabled ||
isOverLimit.value ||
props.creditsRemaining === 0,
);
const containerStyle = computed(() => {
return { minHeight: isMultiline.value ? '80px' : '40px' };
});
const showCredits = computed(() => {
return (
props.creditsQuota !== undefined &&
props.creditsRemaining !== undefined &&
props.creditsQuota !== INFINITE_CREDITS
);
});
const creditsInfo = computed(() => {
if (!showCredits.value || props.creditsRemaining === undefined) return '';
return t('promptInput.creditsInfo', {
remaining: props.creditsRemaining,
total: props.creditsQuota,
});
});
const getNextMonth = () => {
const now = new Date();
const nextMonth = new Date(now.getFullYear(), now.getMonth() + 1, 1);
const options: Intl.DateTimeFormatOptions = { month: 'long', day: 'numeric', year: 'numeric' };
return nextMonth.toLocaleDateString('en-US', options);
};
const creditsTooltipContent = computed(() => {
if (!showCredits.value) return '';
const nextMonthDate = getNextMonth();
const lines = [
t('promptInput.remainingCredits', {
count: props.creditsRemaining ?? 0,
}),
t('promptInput.monthlyCredits', {
count: props.creditsQuota ?? 0,
}),
t('promptInput.creditsRenew', { date: nextMonthDate }),
t('promptInput.creditsExpire', { date: nextMonthDate }),
];
return lines.join('<br />');
});
const hasNoCredits = computed(() => {
return showCredits.value && props.creditsRemaining === 0;
});
const textareaStyle = computed<{ height?: string; overflowY?: 'hidden' }>(() => {
if (!isMultiline.value) {
return {};
@ -218,107 +281,138 @@ defineExpose({
</script>
<template>
<div
ref="containerRef"
:class="[
$style.container,
{
[$style.focused]: isFocused,
[$style.multiline]: isMultiline,
[$style.disabled]: disabled,
},
]"
:style="containerStyle"
>
<!-- Warning banner when character limit is reached -->
<N8nCallout
slim
v-if="showWarningBanner"
icon="info"
theme="warning"
:class="$style.warningCallout"
<div :class="$style.wrapper">
<div
ref="containerRef"
:class="[
$style.container,
{
[$style.focused]: isFocused,
[$style.multiline]: isMultiline,
[$style.disabled]: disabled || hasNoCredits,
[$style.withBottomBorder]: !!showCredits,
},
]"
:style="containerStyle"
>
{{ t('assistantChat.characterLimit', { limit: maxLength.toString() }) }}
</N8nCallout>
<!-- Single line mode: input and button side by side -->
<div v-if="!isMultiline" :class="$style.singleLineWrapper">
<textarea
ref="textareaRef"
v-model="textValue"
data-test-id="chat-input-textarea"
:class="[
$style.singleLineTextarea,
'ignore-key-press-node-creator',
'ignore-key-press-canvas',
]"
:placeholder="placeholder"
:disabled="disabled"
:maxlength="maxLength"
rows="1"
@keydown="handleKeyDown"
@focus="handleFocus"
@blur="handleBlur"
@input="adjustHeight"
/>
<div :class="$style.inlineActions">
<N8nSendStopButton
data-test-id="send-message-button"
:streaming="streaming"
:disabled="sendDisabled"
@send="handleSubmit"
@stop="handleStop"
/>
</div>
</div>
<!-- Multiline mode: textarea full width with button below -->
<template v-else>
<!-- Use ScrollArea when content exceeds max height -->
<N8nScrollArea
:class="$style.scrollAreaWrapper"
:max-height="`${textAreaMaxHeight}px`"
type="auto"
<!-- Warning banner when character limit is reached -->
<N8nCallout
v-if="showWarningBanner"
slim
icon="info"
theme="warning"
:class="$style.warningCallout"
>
{{ t('assistantChat.characterLimit', { limit: maxLength.toString() }) }}
</N8nCallout>
<!-- Single line mode: input and button side by side -->
<div v-if="!isMultiline" :class="$style.singleLineWrapper">
<textarea
ref="textareaRef"
v-model="textValue"
data-test-id="chat-input-textarea"
:class="[
$style.multilineTextarea,
$style.singleLineTextarea,
'ignore-key-press-node-creator',
'ignore-key-press-canvas',
]"
:style="textareaStyle"
:placeholder="placeholder"
:disabled="disabled"
:placeholder="hasNoCredits ? '' : placeholder"
:disabled="disabled || hasNoCredits"
:maxlength="maxLength"
rows="1"
@keydown="handleKeyDown"
@focus="handleFocus"
@blur="handleBlur"
@input="adjustHeight"
/>
</N8nScrollArea>
<div :class="$style.bottomActions">
<N8nSendStopButton
data-test-id="send-message-button"
:streaming="streaming"
:disabled="sendDisabled"
@send="handleSubmit"
@stop="handleStop"
/>
<div :class="$style.inlineActions">
<N8nSendStopButton
data-test-id="send-message-button"
:streaming="streaming"
:disabled="sendDisabled"
@send="handleSubmit"
@stop="handleStop"
/>
</div>
</div>
</template>
<!-- Multiline mode: textarea full width with button below -->
<template v-else>
<!-- Use ScrollArea when content exceeds max height -->
<N8nScrollArea
:class="$style.scrollAreaWrapper"
:max-height="`${textAreaMaxHeight}px`"
type="auto"
>
<textarea
ref="textareaRef"
v-model="textValue"
:class="[
$style.multilineTextarea,
'ignore-key-press-node-creator',
'ignore-key-press-canvas',
]"
:style="textareaStyle"
:placeholder="hasNoCredits ? '' : placeholder"
:disabled="disabled || hasNoCredits"
:maxlength="maxLength"
@keydown="handleKeyDown"
@focus="handleFocus"
@blur="handleBlur"
@input="adjustHeight"
/>
</N8nScrollArea>
<div :class="$style.bottomActions">
<N8nSendStopButton
data-test-id="send-message-button"
:streaming="streaming"
:disabled="sendDisabled"
@send="handleSubmit"
@stop="handleStop"
/>
</div>
</template>
</div>
<!-- Credits bar below input -->
<div v-if="showCredits" :class="$style.creditsBar">
<div :class="$style.creditsInfoWrapper">
<span v-n8n-html="creditsInfo" :class="{ [$style.noCredits]: hasNoCredits }"></span>
<N8nTooltip
:content="creditsTooltipContent"
:popper-class="$style.infoPopper"
placement="top"
>
<N8nIcon icon="info" size="small" />
</N8nTooltip>
</div>
<N8nTooltip
:disabled="!showAskOwnerTooltip"
:content="t('promptInput.askAdminToUpgrade')"
placement="top"
>
<N8nLink size="small" theme="text" @click="() => emit('upgrade-click')">
{{ t('promptInput.getMore') }}
</N8nLink>
</N8nTooltip>
</div>
</div>
</template>
<style lang="scss" module>
.wrapper {
background: var(--color-background-light);
border: 1px solid var(--color-foreground-base);
border-radius: var(--border-radius-large);
}
.container {
position: relative;
display: flex;
flex-direction: column;
background: var(--color-background-xlight);
border: 1px solid var(--color-foreground-base);
border: none;
border-bottom: 1px transparent solid;
border-radius: var(--border-radius-large);
transition:
border-color 0.2s ease,
@ -326,9 +420,14 @@ defineExpose({
padding: var(--spacing-2xs);
box-sizing: border-box;
// if credit bar is showing
&.withBottomBorder {
border-bottom: var(--border-base);
}
&.focused {
border-color: var(--color-secondary);
box-shadow: 0 0 0 1px var(--color-secondary-tint-2);
box-shadow: 0 0 0 1px var(--color-secondary);
border-bottom: 1px transparent solid;
}
&.multiline {
@ -336,7 +435,7 @@ defineExpose({
}
&.disabled {
background-color: var(--color-foreground-xlight);
background-color: var(--color-background-base);
cursor: not-allowed;
textarea {
@ -421,6 +520,40 @@ defineExpose({
margin-top: auto;
}
// Credits bar below input
.creditsBar {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--spacing-2xs) var(--spacing-xs);
border: none;
}
.creditsInfoWrapper {
display: flex;
align-items: center;
gap: var(--spacing-3xs);
color: var(--color-text-base);
font-size: var(--font-size-2xs);
b {
font-weight: var(--font-weight-bold);
}
}
.infoPopper {
min-width: 200px;
line-height: 18px;
b {
font-weight: var(--font-weight-bold);
}
}
.noCredits {
color: var(--color-danger);
}
// Common styles
.characterCount {
font-size: var(--font-size-3xs);

View File

@ -3,59 +3,64 @@
exports[`N8nPromptInput > rendering > should render correctly with default props 1`] = `
<div>
<div
class="container"
style="min-height: 40px;"
class="wrapper"
>
<!-- Warning banner when character limit is reached -->
<!--v-if-->
<!-- Single line mode: input and button side by side -->
<div
class="singleLineWrapper"
class="container"
style="min-height: 40px;"
>
<textarea
class="singleLineTextarea ignore-key-press-node-creator ignore-key-press-canvas"
data-test-id="chat-input-textarea"
maxlength="1000"
placeholder=""
rows="1"
/>
<!-- Warning banner when character limit is reached -->
<!--v-if-->
<!-- Single line mode: input and button side by side -->
<div
class="inlineActions"
class="singleLineWrapper"
>
<button
aria-disabled="true"
aria-live="polite"
class="button button primary small disabled withIcon square sendButton sendButton"
data-test-id="send-message-button"
disabled=""
<textarea
class="singleLineTextarea ignore-key-press-node-creator ignore-key-press-canvas"
maxlength="1000"
placeholder=""
rows="1"
/>
<div
class="inlineActions"
>
<span
class="icon"
<button
aria-disabled="true"
aria-live="polite"
class="button button primary small disabled withIcon square sendButton sendButton"
data-test-id="send-message-button"
disabled=""
>
<svg
aria-hidden="true"
class="n8n-icon"
data-icon="arrow-up"
focusable="false"
height="16px"
role="img"
viewBox="0 0 24 24"
width="16px"
<span
class="icon"
>
<path
d="m5 12l7-7l7 7m-7 7V5"
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
/>
</svg>
</span>
<!--v-if-->
</button>
<svg
aria-hidden="true"
class="n8n-icon"
data-icon="arrow-up"
focusable="false"
height="16px"
role="img"
viewBox="0 0 24 24"
width="16px"
>
<path
d="m5 12l7-7l7 7m-7 7V5"
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
/>
</svg>
</span>
<!--v-if-->
</button>
</div>
</div>
</div>
<!-- Credits bar below input -->
<!--v-if-->
</div>
</div>
`;
@ -63,58 +68,63 @@ exports[`N8nPromptInput > rendering > should render correctly with default props
exports[`N8nPromptInput > rendering > should render streaming state without disabling textarea 1`] = `
<div>
<div
class="container"
style="min-height: 40px;"
class="wrapper"
>
<!-- Warning banner when character limit is reached -->
<!--v-if-->
<!-- Single line mode: input and button side by side -->
<div
class="singleLineWrapper"
class="container"
style="min-height: 40px;"
>
<textarea
class="singleLineTextarea ignore-key-press-node-creator ignore-key-press-canvas"
data-test-id="chat-input-textarea"
maxlength="1000"
placeholder=""
rows="1"
/>
<!-- Warning banner when character limit is reached -->
<!--v-if-->
<!-- Single line mode: input and button side by side -->
<div
class="inlineActions"
class="singleLineWrapper"
>
<button
aria-live="polite"
class="button button primary small withIcon square stopButton stopButton"
data-test-id="send-message-button"
<textarea
class="singleLineTextarea ignore-key-press-node-creator ignore-key-press-canvas"
maxlength="1000"
placeholder=""
rows="1"
/>
<div
class="inlineActions"
>
<span
class="icon"
<button
aria-live="polite"
class="button button primary small withIcon square stopButton stopButton"
data-test-id="send-message-button"
>
<svg
aria-hidden="true"
class="n8n-icon"
data-icon="filled-square"
fill="currentColor"
focusable="false"
height="12px"
overflow="hidden"
role="img"
viewBox="0 0 10 10"
width="12px"
xmlns="http://www.w3.org/2000/svg"
<span
class="icon"
>
<rect
height="10"
rx="2"
ry="2"
width="10"
/>
</svg>
</span>
<!--v-if-->
</button>
<svg
aria-hidden="true"
class="n8n-icon"
data-icon="filled-square"
fill="currentColor"
focusable="false"
height="12px"
overflow="hidden"
role="img"
viewBox="0 0 10 10"
width="12px"
xmlns="http://www.w3.org/2000/svg"
>
<rect
height="10"
rx="2"
ry="2"
width="10"
/>
</svg>
</span>
<!--v-if-->
</button>
</div>
</div>
</div>
<!-- Credits bar below input -->
<!--v-if-->
</div>
</div>
`;

View File

@ -78,4 +78,12 @@ export default {
'tableControlsButton.display': 'Display',
'tableControlsButton.shown': 'Shown',
'tableControlsButton.hidden': 'Hidden',
'promptInput.creditsInfo': '<b>{remaining}/{total}</b> monthly credits left',
'promptInput.getMore': 'Get more',
'promptInput.askAdminToUpgrade': 'Ask your admin to upgrade the instance to get more credits',
'promptInput.characterLimitReached': "You've reached the {limit} character limit",
'promptInput.remainingCredits': 'Remaining builder AI credits: <b>{count}</b>',
'promptInput.monthlyCredits': 'Monthly credits: <b>{count}</b>',
'promptInput.creditsRenew': 'Credits renew on: <b>{date}</b>',
'promptInput.creditsExpire': 'Unused credits expire {date}',
} as N8nLocale;

View File

@ -1127,7 +1127,9 @@ export type CloudUpdateLinkSourceType =
| 'rbac'
| 'debug'
| 'insights'
| 'evaluations';
| 'evaluations'
| 'ai-builder-sidebar'
| 'ai-builder-canvas';
export type UTMCampaign =
| 'upgrade-custom-data-filter'
@ -1152,7 +1154,8 @@ export type UTMCampaign =
| 'upgrade-rbac'
| 'upgrade-debug'
| 'upgrade-insights'
| 'upgrade-evaluations';
| 'upgrade-evaluations'
| 'upgrade-builder';
export type N8nBanners = {
[key in BannerName]: {

View File

@ -1,4 +1,4 @@
import { chatWithBuilder } from './ai';
import { chatWithBuilder, getBuilderCredits } from './ai';
import * as apiUtils from '@n8n/rest-api-client';
import type { IRestApiContext } from '@n8n/rest-api-client';
import type { ChatRequest } from '@/types/assistant.types';
@ -373,4 +373,44 @@ describe('API: ai', () => {
);
});
});
describe('getBuilderCredits', () => {
let mockContext: IRestApiContext;
let makeRestApiRequestSpy: MockInstance;
beforeEach(() => {
mockContext = {
baseUrl: 'http://test-base-url',
sessionId: 'test-session',
pushRef: 'test-ref',
} as IRestApiContext;
makeRestApiRequestSpy = vi.spyOn(apiUtils, 'makeRestApiRequest');
});
afterEach(() => {
vi.clearAllMocks();
});
it('should call makeRestApiRequest with correct parameters and return credits data', async () => {
const mockResponse = {
creditsQuota: 1000,
creditsClaimed: 500,
};
makeRestApiRequestSpy.mockResolvedValue(mockResponse);
const result = await getBuilderCredits(mockContext);
expect(makeRestApiRequestSpy).toHaveBeenCalledWith(mockContext, 'GET', '/ai/build/credits');
expect(result).toEqual(mockResponse);
});
it('should handle API errors', async () => {
const error = new Error('API request failed');
makeRestApiRequestSpy.mockRejectedValue(error);
await expect(getBuilderCredits(mockContext)).rejects.toThrow('API request failed');
});
});
});

View File

@ -106,3 +106,10 @@ export async function getAiSessions(
workflowId,
} as IDataObject);
}
export async function getBuilderCredits(ctx: IRestApiContext): Promise<{
creditsQuota: number;
creditsClaimed: number;
}> {
return await makeRestApiRequest(ctx, 'GET', '/ai/build/credits');
}

View File

@ -1,4 +1,14 @@
import { describe, it, expect, vi, beforeEach, beforeAll } from 'vitest';
import { defineComponent, h } from 'vue';
// Type for Vue component instance with setup state
interface VueComponentInstance {
__vueParentComponent?: {
setupState?: {
onUserMessage?: (message: string) => Promise<void>;
};
};
}
// Mock workflow saving first before any other imports
const saveCurrentWorkflowMock = vi.hoisted(() => vi.fn());
@ -12,6 +22,64 @@ vi.mock('@/composables/useWorkflowSaving', () => ({
}),
}));
// Mock AskAssistantChat component
vi.mock('@n8n/design-system/components/AskAssistantChat/AskAssistantChat.vue', () => ({
default: defineComponent({
name: 'AskAssistantChat',
props: [
'user',
'messages',
'streaming',
'loadingMessage',
'creditsQuota',
'creditsRemaining',
'showAskOwnerTooltip',
],
emits: ['message', 'feedback', 'stop', 'upgrade-click'],
setup(props, { emit, expose }) {
const feedbackText = { value: '' };
const sendMessage = (message: string) => {
emit('message', message);
};
expose({ sendMessage });
// Create a more realistic mock that includes rating buttons when needed
return () => {
const lastMessage = props.messages?.[props.messages.length - 1];
const showRating = lastMessage?.showRating;
return h('div', { 'data-test-id': 'mocked-assistant-chat' }, [
showRating
? [
h('button', {
'data-test-id': 'message-thumbs-up-button',
onClick: () => emit('feedback', { rating: 'up' }),
}),
h('button', {
'data-test-id': 'message-thumbs-down-button',
onClick: () => {
emit('feedback', { rating: 'down' });
},
}),
h('input', {
'data-test-id': 'message-feedback-input',
onInput: (e: Event) => {
feedbackText.value = (e.target as HTMLInputElement).value;
},
}),
h('button', {
'data-test-id': 'message-submit-feedback-button',
onClick: () => emit('feedback', { rating: 'down', feedback: feedbackText.value }),
}),
]
: null,
]);
};
},
}),
}));
import { createComponentRenderer } from '@/__tests__/render';
import { setActivePinia } from 'pinia';
import { createTestingPinia } from '@pinia/testing';
@ -67,6 +135,14 @@ vi.mock('vue-router', () => {
};
});
// Mock usePageRedirectionHelper
const goToUpgradeMock = vi.fn();
vi.mock('@/composables/usePageRedirectionHelper', () => ({
usePageRedirectionHelper: () => ({
goToUpgrade: goToUpgradeMock,
}),
}));
const workflowPrompt = 'Create a workflow';
describe('AskAssistantBuild', () => {
const sessionId = faker.string.uuid();
@ -89,6 +165,17 @@ describe('AskAssistantBuild', () => {
streaming: false,
assistantThinkingMessage: undefined,
workflowPrompt,
creditsQuota: -1,
creditsRemaining: 0,
},
[STORES.USERS]: {
currentUser: {
id: '1',
firstName: 'Test',
lastName: 'User',
email: 'test@example.com',
},
isInstanceOwner: true,
},
},
});
@ -109,14 +196,14 @@ describe('AskAssistantBuild', () => {
builderStore.toolMessages = [];
builderStore.workflowPrompt = workflowPrompt;
builderStore.trackingSessionId = 'app_session_id';
workflowsStore.workflowId = 'abc123';
});
describe('rendering', () => {
it('should render component correctly', () => {
const { getByTestId } = renderComponent();
expect(getByTestId('ask-assistant-chat')).toBeInTheDocument();
// The wrapper div has the data-test-id, but we verify the mocked chat is rendered
expect(getByTestId('mocked-assistant-chat')).toBeInTheDocument();
});
it('should pass correct props to AskAssistantChat component', () => {
@ -130,18 +217,18 @@ describe('AskAssistantBuild', () => {
describe('user message handling', () => {
it('should initialize builder chat when a user sends a message', async () => {
const { getByTestId } = renderComponent();
// Mock empty workflow to ensure initialGeneration is true
workflowsStore.$patch({ workflow: { nodes: [], connections: {} } });
workflowsStore.isNewWorkflow = false;
const { container } = renderComponent();
const testMessage = 'Create a workflow to send emails';
// Type message into the chat input
const chatInput = getByTestId('chat-input');
const textarea = chatInput.querySelector('textarea');
if (!textarea) throw new Error('Textarea not found');
await fireEvent.update(textarea, testMessage);
// Trigger submit using Enter key (N8nPromptInput submits on Enter)
await fireEvent.keyDown(textarea, { key: 'Enter' });
// Find the component instance and directly call the onUserMessage handler
const vm = (container.firstElementChild as VueComponentInstance)?.__vueParentComponent;
if (vm?.setupState?.onUserMessage) {
await vm.setupState.onUserMessage(testMessage);
}
await flushPromises();
@ -328,15 +415,17 @@ describe('AskAssistantBuild', () => {
workflowsStore.$patch({ workflow: { nodes: [], connections: {} } });
workflowsStore.isNewWorkflow = false;
const { findByTestId } = renderComponent();
const wrapper = renderComponent();
// Send initial message to start generation
const testMessage = 'Create a workflow to send emails';
const chatInput = await findByTestId('chat-input');
const textarea = chatInput.querySelector('textarea');
if (!textarea) throw new Error('Textarea not found');
await fireEvent.update(textarea, testMessage);
await fireEvent.keyDown(textarea, { key: 'Enter' });
const vm = (wrapper.container.firstElementChild as VueComponentInstance)
?.__vueParentComponent;
if (vm?.setupState?.onUserMessage) {
await vm.setupState.onUserMessage(testMessage);
}
await flushPromises();
expect(builderStore.sendChatMessage).toHaveBeenCalledWith({
initialGeneration: true,
@ -400,15 +489,17 @@ describe('AskAssistantBuild', () => {
});
workflowsStore.isNewWorkflow = false;
const { findByTestId } = renderComponent();
const wrapper = renderComponent();
// Send message to modify existing workflow
const testMessage = 'Add an HTTP node';
const chatInput = await findByTestId('chat-input');
const textarea = chatInput.querySelector('textarea');
if (!textarea) throw new Error('Textarea not found');
await fireEvent.update(textarea, testMessage);
await fireEvent.keyDown(textarea, { key: 'Enter' });
const vm = (wrapper.container.firstElementChild as VueComponentInstance)
?.__vueParentComponent;
if (vm?.setupState?.onUserMessage) {
await vm.setupState.onUserMessage(testMessage);
}
await flushPromises();
expect(builderStore.sendChatMessage).toHaveBeenCalledWith({
initialGeneration: false,
@ -457,17 +548,16 @@ describe('AskAssistantBuild', () => {
workflowsStore.$patch({ workflow: { nodes: [], connections: {} } });
workflowsStore.isNewWorkflow = false;
const { findByTestId } = renderComponent();
const wrapper = renderComponent();
// Send initial message
const testMessage = 'Create a workflow';
const chatInput = await findByTestId('chat-input');
const textarea = chatInput.querySelector('textarea');
if (!textarea) throw new Error('Textarea not found');
await fireEvent.update(textarea, testMessage);
await fireEvent.keyDown(textarea, { key: 'Enter' });
const vm = (wrapper.container.firstElementChild as VueComponentInstance)
?.__vueParentComponent;
if (vm?.setupState?.onUserMessage) {
await vm.setupState.onUserMessage(testMessage);
}
// The component should have set initialGeneration to true since workflow was empty
await flushPromises();
// Simulate streaming starts
@ -512,15 +602,15 @@ describe('AskAssistantBuild', () => {
workflowsStore.$patch({ workflow: { nodes: [], connections: {} } });
workflowsStore.isNewWorkflow = false;
const { findByTestId } = renderComponent();
const wrapper = renderComponent();
// Send initial message
const testMessage = 'Create a workflow';
const chatInput = await findByTestId('chat-input');
const textarea = chatInput.querySelector('textarea');
if (!textarea) throw new Error('Textarea not found');
await fireEvent.update(textarea, testMessage);
await fireEvent.keyDown(textarea, { key: 'Enter' });
const vm = (wrapper.container.firstElementChild as VueComponentInstance)
?.__vueParentComponent;
if (vm?.setupState?.onUserMessage) {
await vm.setupState.onUserMessage(testMessage);
}
// Simulate streaming starts
builderStore.$patch({ streaming: true });
@ -564,15 +654,15 @@ describe('AskAssistantBuild', () => {
workflowsStore.isNewWorkflow = true;
workflowsStore.$patch({ workflow: { nodes: [], connections: {} } });
const { findByTestId } = renderComponent();
const wrapper = renderComponent();
// Send initial message
const testMessage = 'Create a workflow';
const chatInput = await findByTestId('chat-input');
const textarea = chatInput.querySelector('textarea');
if (!textarea) throw new Error('Textarea not found');
await fireEvent.update(textarea, testMessage);
await fireEvent.keyDown(textarea, { key: 'Enter' });
const vm = (wrapper.container.firstElementChild as VueComponentInstance)
?.__vueParentComponent;
if (vm?.setupState?.onUserMessage) {
await vm.setupState.onUserMessage(testMessage);
}
await flushPromises();
@ -594,15 +684,15 @@ describe('AskAssistantBuild', () => {
],
});
const { findByTestId } = renderComponent();
const wrapper = renderComponent();
// Send new message in existing session
const testMessage = 'Add email nodes';
const chatInput = await findByTestId('chat-input');
const textarea = chatInput.querySelector('textarea');
if (!textarea) throw new Error('Textarea not found');
await fireEvent.update(textarea, testMessage);
await fireEvent.keyDown(textarea, { key: 'Enter' });
const vm = (wrapper.container.firstElementChild as VueComponentInstance)
?.__vueParentComponent;
if (vm?.setupState?.onUserMessage) {
await vm.setupState.onUserMessage(testMessage);
}
// Simulate streaming starts
builderStore.$patch({ streaming: true, initialGeneration: true });
@ -662,7 +752,7 @@ describe('AskAssistantBuild', () => {
});
workflowsStore.isNewWorkflow = false;
const { findByTestId } = renderComponent();
const wrapper = renderComponent();
// User manually deletes all nodes (simulated by clearing workflow)
workflowsStore.$patch({ workflow: { nodes: [], connections: {} } });
@ -670,11 +760,13 @@ describe('AskAssistantBuild', () => {
// Send message to generate new workflow
const testMessage = 'Create a new workflow';
const chatInput = await findByTestId('chat-input');
const textarea = chatInput.querySelector('textarea');
if (!textarea) throw new Error('Textarea not found');
await fireEvent.update(textarea, testMessage);
await fireEvent.keyDown(textarea, { key: 'Enter' });
const vm = (wrapper.container.firstElementChild as VueComponentInstance)
?.__vueParentComponent;
if (vm?.setupState?.onUserMessage) {
await vm.setupState.onUserMessage(testMessage);
}
await flushPromises();
expect(builderStore.sendChatMessage).toHaveBeenCalledWith({
initialGeneration: true,
@ -728,15 +820,15 @@ describe('AskAssistantBuild', () => {
workflowsStore.$patch({ workflow: { nodes: [], connections: {} } });
workflowsStore.isNewWorkflow = false;
const { findByTestId } = renderComponent();
const wrapper = renderComponent();
// Send message
const testMessage = 'Create a workflow';
const chatInput = await findByTestId('chat-input');
const textarea = chatInput.querySelector('textarea');
if (!textarea) throw new Error('Textarea not found');
await fireEvent.update(textarea, testMessage);
await fireEvent.keyDown(textarea, { key: 'Enter' });
const vm = (wrapper.container.firstElementChild as VueComponentInstance)
?.__vueParentComponent;
if (vm?.setupState?.onUserMessage) {
await vm.setupState.onUserMessage(testMessage);
}
// Simulate streaming starts
builderStore.$patch({ streaming: true });

View File

@ -11,6 +11,7 @@ import { useWorkflowSaving } from '@/composables/useWorkflowSaving';
import type { RatingFeedback } from '@n8n/design-system/types/assistant';
import { isWorkflowUpdatedMessage } from '@n8n/design-system/types/assistant';
import { nodeViewEventBus } from '@/event-bus';
import { usePageRedirectionHelper } from '@/composables/usePageRedirectionHelper';
const emit = defineEmits<{
close: [];
@ -24,6 +25,7 @@ const i18n = useI18n();
const route = useRoute();
const router = useRouter();
const workflowSaver = useWorkflowSaving({ router });
const { goToUpgrade } = usePageRedirectionHelper();
// Track processed workflow updates
const processedWorkflowUpdates = ref(new Set<string>());
@ -38,6 +40,9 @@ const user = computed(() => ({
const loadingMessage = computed(() => builderStore.assistantThinkingMessage);
const currentRoute = computed(() => route.name);
const creditsQuota = computed(() => builderStore.creditsQuota);
const creditsRemaining = computed(() => builderStore.creditsRemaining);
const showAskOwnerTooltip = computed(() => usersStore.isInstanceOwner !== false);
async function onUserMessage(content: string) {
const isNewWorkflow = workflowsStore.isNewWorkflow;
@ -190,9 +195,13 @@ watch(currentRoute, () => {
:title="'n8n AI'"
:show-stop="true"
:scroll-on-new-message="true"
:credits-quota="creditsQuota"
:credits-remaining="creditsRemaining"
:show-ask-owner-tooltip="showAskOwnerTooltip"
:inputPlaceholder="i18n.baseText('aiAssistant.builder.assistantPlaceholder')"
@close="emit('close')"
@message="onUserMessage"
@upgrade-click="() => goToUpgrade('ai-builder-sidebar', 'upgrade-builder')"
@feedback="onFeedback"
@stop="builderStore.stopStreaming"
>

View File

@ -11,14 +11,28 @@ import { WORKFLOW_SUGGESTIONS } from '@/constants/workflowSuggestions';
const streaming = ref(false);
const openChat = vi.fn();
const sendChatMessage = vi.fn();
const stopStreaming = vi.fn();
const creditsQuota = ref(100);
const creditsRemaining = ref(0);
const hasNoCreditsRemaining = ref(false);
vi.mock('@/stores/builder.store', () => {
return {
useBuilderStore: vi.fn(() => ({
get streaming() {
return streaming.value;
},
get creditsQuota() {
return creditsQuota.value;
},
get creditsRemaining() {
return creditsRemaining.value;
},
get hasNoCreditsRemaining() {
return hasNoCreditsRemaining.value;
},
openChat,
sendChatMessage,
stopStreaming,
})),
};
});
@ -41,6 +55,15 @@ vi.mock('@/stores/nodeCreator.store', () => ({
})),
}));
const isInstanceOwner = ref(true);
vi.mock('@/stores/users.store', () => ({
useUsersStore: vi.fn(() => ({
get isInstanceOwner() {
return isInstanceOwner.value;
},
})),
}));
// Mock composables
const saveCurrentWorkflow = vi.fn();
vi.mock('@/composables/useWorkflowSaving', () => ({
@ -63,12 +86,65 @@ vi.mock('@/composables/useTelemetry', () => ({
})),
}));
const goToUpgrade = vi.fn();
vi.mock('@/composables/usePageRedirectionHelper', () => ({
usePageRedirectionHelper: vi.fn(() => ({
goToUpgrade,
})),
}));
// Mock router
vi.mock('vue-router', () => ({
useRouter: vi.fn(),
RouterLink: vi.fn(),
}));
// Mock N8nPromptInput component
vi.mock('@n8n/design-system/components/N8nPromptInput/N8nPromptInput.vue', () => ({
default: {
name: 'N8nPromptInput',
props: [
'modelValue',
'disabled',
'streaming',
'placeholder',
'maxLinesBeforeScroll',
'creditsQuota',
'creditsRemaining',
'showAskOwnerTooltip',
'minLines',
],
emits: ['update:modelValue', 'submit', 'stop', 'input', 'upgrade-click'],
template: `
<div class="n8n-prompt-input">
<textarea
:value="modelValue"
:disabled="disabled"
:placeholder="placeholder"
@input="$emit('update:modelValue', $event.target.value); $emit('input', $event)"
@keydown.enter.exact="$emit('submit')"
/>
<button v-if="streaming" @click="$emit('stop')">Stop</button>
<button v-else :disabled="!modelValue || disabled" @click="$emit('submit')">Send</button>
</div>
`,
},
}));
// Mock n8n-icon component
vi.mock('@n8n/design-system', async () => {
const actual = await vi.importActual('@n8n/design-system');
return {
...actual,
N8nIcon: {
name: 'n8n-icon',
props: ['icon', 'size'],
// eslint-disable-next-line n8n-local-rules/no-interpolation-in-regular-string
template: '<span :class="`n8n-icon-${icon}`"></span>',
},
};
});
const renderComponent = createComponentRenderer(CanvasNodeAIPrompt);
describe('CanvasNodeAIPrompt', () => {
@ -80,6 +156,9 @@ describe('CanvasNodeAIPrompt', () => {
vi.clearAllMocks();
streaming.value = false;
isNewWorkflow.value = false;
hasNoCreditsRemaining.value = false;
creditsQuota.value = 100;
creditsRemaining.value = 100;
});
// Snapshot Test
@ -239,6 +318,41 @@ describe('CanvasNodeAIPrompt', () => {
});
});
it('should disable suggestion pills when user has no credits remaining', () => {
hasNoCreditsRemaining.value = true;
creditsRemaining.value = 0;
const { container } = renderComponent();
const pills = container.querySelectorAll('[role="group"] button');
pills.forEach((pill) => {
expect(pill).toHaveAttribute('disabled');
});
});
it('should enable suggestion pills when user has credits remaining', () => {
hasNoCreditsRemaining.value = false;
creditsRemaining.value = 50;
streaming.value = false;
const { container } = renderComponent();
const pills = container.querySelectorAll('[role="group"] button');
pills.forEach((pill) => {
expect(pill).not.toHaveAttribute('disabled');
});
});
it('should disable suggestion pills when both streaming and no credits', () => {
streaming.value = true;
hasNoCreditsRemaining.value = true;
creditsRemaining.value = 0;
const { container } = renderComponent();
const pills = container.querySelectorAll('[role="group"] button');
pills.forEach((pill) => {
expect(pill).toHaveAttribute('disabled');
});
});
it('should replace prompt when suggestion is clicked', async () => {
const { container } = renderComponent();
const firstPill = container.querySelector('[role="group"] button');
@ -321,6 +435,29 @@ describe('CanvasNodeAIPrompt', () => {
// Prompt should not be replaced
expect(textarea).toHaveValue(originalValue);
});
it('should not replace prompt when clicking disabled pill with no credits', async () => {
// Note: This test documents the current behavior where disabled buttons
// can still be clicked in test environments and their click handlers are triggered.
// In real browsers, disabled buttons typically don't fire click events.
hasNoCreditsRemaining.value = true;
creditsRemaining.value = 0;
const { container } = renderComponent();
const textarea = container.querySelector('textarea');
const firstPill = container.querySelector('[role="group"] button');
if (!textarea || !firstPill) throw new Error('Elements not found');
// Try clicking the disabled pill
await fireEvent.click(firstPill);
// In test environment, click handler still fires on disabled buttons
expect(telemetryTrack).not.toHaveBeenCalled();
// The prompt gets replaced even though button is disabled
// (This is test environment behavior, not real browser behavior)
expect(textarea).toHaveValue('');
});
});
describe('manual node creation', () => {

View File

@ -6,9 +6,11 @@ import { useBuilderStore } from '@/stores/builder.store';
import { useRouter } from 'vue-router';
import { useWorkflowSaving } from '@/composables/useWorkflowSaving';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { usePageRedirectionHelper } from '@/composables/usePageRedirectionHelper';
import { useMessage } from '@/composables/useMessage';
import { useTelemetry } from '@/composables/useTelemetry';
import { useNodeCreatorStore } from '@/stores/nodeCreator.store';
import { useUsersStore } from '@/stores/users.store';
import { MODAL_CONFIRM, NODE_CREATOR_OPEN_SOURCES } from '@/constants';
import { WORKFLOW_SUGGESTIONS } from '@/constants/workflowSuggestions';
import type { WorkflowSuggestion } from '@/constants/workflowSuggestions';
@ -23,9 +25,11 @@ const telemetry = useTelemetry();
const nodeCreatorStore = useNodeCreatorStore();
const builderStore = useBuilderStore();
const workflowsStore = useWorkflowsStore();
const usersStore = useUsersStore();
// Services
const workflowSaver = useWorkflowSaving({ router });
const { goToUpgrade } = usePageRedirectionHelper();
// Component state
const prompt = ref('');
@ -34,6 +38,9 @@ const isLoading = ref(false);
// Computed properties
const hasContent = computed(() => prompt.value.trim().length > 0);
const creditsQuota = computed(() => builderStore.creditsQuota);
const creditsRemaining = computed(() => builderStore.creditsRemaining);
const showAskOwnerTooltip = computed(() => usersStore.isInstanceOwner === false);
// Static data
const suggestions = ref(WORKFLOW_SUGGESTIONS);
@ -64,6 +71,10 @@ async function onSubmit() {
* @param suggestion - The workflow suggestion that was clicked
*/
async function onSuggestionClick(suggestion: WorkflowSuggestion) {
if (builderStore.hasNoCreditsRemaining === true) {
return;
}
// Track telemetry
telemetry.track('User clicked suggestion pill', {
prompt: prompt.value,
@ -125,9 +136,13 @@ function onAddNodeClick() {
:streaming="builderStore.streaming"
:placeholder="i18n.baseText('aiAssistant.builder.placeholder')"
:max-lines-before-scroll="4"
:credits-quota="creditsQuota"
:credits-remaining="creditsRemaining"
:show-ask-owner-tooltip="showAskOwnerTooltip"
:min-lines="2"
data-test-id="ai-builder-prompt"
@submit="onSubmit"
@upgrade-click="() => goToUpgrade('ai-builder-canvas', 'upgrade-builder')"
@stop="builderStore.stopStreaming"
@input="userEditedPrompt = true"
/>
@ -139,7 +154,7 @@ function onAddNodeClick() {
v-for="suggestion in suggestions"
:key="suggestion.id"
:class="$style.suggestionPill"
:disabled="builderStore.streaming"
:disabled="builderStore.streaming || builderStore.hasNoCreditsRemaining"
type="button"
@click="onSuggestionClick(suggestion)"
>
@ -232,6 +247,10 @@ function onAddNodeClick() {
border-color: var(--color-primary);
background: var(--color-background-xlight);
}
&:disabled {
cursor: not-allowed;
}
}
/* Divider Section */

View File

@ -6,42 +6,11 @@ exports[`CanvasNodeAIPrompt > should render component correctly 1`] = `
<h2 class="title">What would you like to automate?</h2>
</header><!-- Prompt input section -->
<section class="promptContainer">
<div class="container multiline promptInput" style="min-height: 80px;" data-test-id="ai-builder-prompt">
<!-- Warning banner when character limit is reached -->
<!--v-if-->
<!-- Single line mode: input and button side by side -->
<!-- Multiline mode: textarea full width with button below -->
<!-- Use ScrollArea when content exceeds max height -->
<div dir="ltr" style="position: relative; --reka-scroll-area-corner-width: 0px; --reka-scroll-area-corner-height: 0px;" class="scrollAreaRoot scrollAreaWrapper">
<div data-reka-scroll-area-viewport="" style="overflow-x: hidden; overflow-y: hidden; max-height: 72px;" class="viewport" tabindex="0">
<div><textarea data-test-id="chat-input-textarea" class="multilineTextarea ignore-key-press-node-creator ignore-key-press-canvas" style="height: 36px; overflow-y: hidden;" placeholder="Ask n8n to build..." maxlength="1000"></textarea></div>
</div>
<style>
/* Hide scrollbars cross-browser and enable momentum scroll for touch devices */
[data-reka-scroll-area-viewport] {
scrollbar-width: none;
-ms-overflow-style: none;
-webkit-overflow-scrolling: touch;
}
[data-reka-scroll-area-viewport]::-webkit-scrollbar {
display: none;
}
</style>
<!---->
<!--v-if-->
<!--v-if-->
</div>
<div class="bottomActions"><button class="button button primary small disabled withIcon square sendButton sendButton" disabled="" aria-disabled="true" aria-live="polite" data-test-id="send-message-button"><span class="icon"><svg viewBox="0 0 24 24" width="16px" height="16px" class="n8n-icon" aria-hidden="true" focusable="false" role="img" data-icon="arrow-up"><path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m5 12l7-7l7 7m-7 7V5"></path></svg></span>
<!--v-if-->
</button></div>
</div>
<div class="n8n-prompt-input promptInput" data-test-id="ai-builder-prompt"><textarea placeholder="Ask n8n to build..." value=""></textarea><button disabled="">Send</button></div>
</section><!-- Suggestion pills section -->
<section class="pillsContainer" role="group" aria-label="Workflow suggestions"><button class="suggestionPill" type="button">Invoice processing pipeline</button><button class="suggestionPill" type="button">Daily AI news digest</button><button class="suggestionPill" type="button">RAG knowledge assistant</button><button class="suggestionPill" type="button">Summarize emails with AI</button><button class="suggestionPill" type="button">YouTube video chapters</button><button class="suggestionPill" type="button">Pizza delivery chatbot</button><button class="suggestionPill" type="button">Lead qualification and call scheduling</button><button class="suggestionPill" type="button">Multi-agent research workflow</button></section><!-- Divider -->
<div class="orDivider" role="separator"><span class="orText">or</span></div><!-- Manual node creation section -->
<section class="startManually"><button class="addButton" type="button" aria-label="Add node manually"><svg viewBox="0 0 24 24" width="40px" height="40px" class="n8n-icon" aria-hidden="true" focusable="false" role="img" data-icon="plus">
<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h14m-7-7v14"></path>
</svg></button>
<section class="startManually"><button class="addButton" type="button" aria-label="Add node manually"><span class="n8n-icon-plus"></span></button>
<div class="startManuallyLabel"><strong class="startManuallyText">Start manually</strong><span class="startManuallySubtitle">Add the first node</span></div>
</section>
</article>"

View File

@ -0,0 +1,30 @@
import { builderCreditsUpdated } from './builderCreditsUpdated';
import type { BuilderCreditsPushMessage } from '@n8n/api-types/push/builder-credits';
import { useBuilderStore } from '@/stores/builder.store';
vi.mock('@/stores/builder.store', () => ({
useBuilderStore: vi.fn(),
}));
describe('builderCreditsUpdated', () => {
it('should update builder credits in the store', async () => {
const mockUpdateBuilderCredits = vi.fn();
const mockStore = {
updateBuilderCredits: mockUpdateBuilderCredits,
} as unknown as ReturnType<typeof useBuilderStore>;
vi.mocked(useBuilderStore).mockReturnValue(mockStore);
const event: BuilderCreditsPushMessage = {
type: 'updateBuilderCredits',
data: {
creditsQuota: 1000,
creditsClaimed: 250,
},
};
await builderCreditsUpdated(event);
expect(mockUpdateBuilderCredits).toHaveBeenCalledWith(1000, 250);
});
});

View File

@ -0,0 +1,9 @@
import type { BuilderCreditsPushMessage } from '@n8n/api-types/push/builder-credits';
import { useBuilderStore } from '@/stores/builder.store';
export async function builderCreditsUpdated(event: BuilderCreditsPushMessage): Promise<void> {
const builderStore = useBuilderStore();
// Update the builder store with new credits values
builderStore.updateBuilderCredits(event.data.creditsQuota, event.data.creditsClaimed);
}

View File

@ -1,3 +1,4 @@
export * from './builderCreditsUpdated';
export * from './executionFinished';
export * from './executionRecovered';
export * from './executionStarted';

View File

@ -1,6 +1,10 @@
import { usePushConnection } from '@/composables/usePushConnection';
import { testWebhookReceived } from '@/composables/usePushConnection/handlers';
import {
testWebhookReceived,
builderCreditsUpdated,
} from '@/composables/usePushConnection/handlers';
import type { TestWebhookReceived } from '@n8n/api-types/push/webhook';
import type { BuilderCreditsPushMessage } from '@n8n/api-types/push/builder-credits';
import { useRouter } from 'vue-router';
import type { OnPushMessageHandler } from '@/stores/pushConnection.store';
@ -33,6 +37,7 @@ vi.mock('@/composables/usePushConnection/handlers', () => ({
workflowActivated: vi.fn(),
workflowDeactivated: vi.fn(),
collaboratorsChanged: vi.fn(),
builderCreditsUpdated: vi.fn(),
}));
vi.mock('vue-router', async () => {
@ -91,4 +96,30 @@ describe('usePushConnection composable', () => {
expect(removeEventListener).toHaveBeenCalledTimes(1);
});
it('should handle updateBuilderCredits event correctly', async () => {
pushConnection.initialize();
// Get the event callback which was registered via addEventListener.
const handler = addEventListener.mock.calls[0][0];
// Create a test event for updateBuilderCredits.
const testEvent: BuilderCreditsPushMessage = {
type: 'updateBuilderCredits',
data: {
creditsQuota: 1000,
creditsClaimed: 250,
},
};
// Call the event callback with our test event.
handler(testEvent);
// Allow any microtasks to complete.
await Promise.resolve();
// Verify that the correct handler was called.
expect(builderCreditsUpdated).toHaveBeenCalledTimes(1);
expect(builderCreditsUpdated).toHaveBeenCalledWith(testEvent);
});
});

View File

@ -3,6 +3,7 @@ import type { PushMessage } from '@n8n/api-types';
import { usePushConnectionStore } from '@/stores/pushConnection.store';
import {
builderCreditsUpdated,
testWebhookDeleted,
testWebhookReceived,
reloadNodeType,
@ -79,6 +80,8 @@ export function usePushConnection(options: { router: ReturnType<typeof useRouter
return await workflowActivated(event);
case 'workflowDeactivated':
return await workflowDeactivated(event);
case 'updateBuilderCredits':
return await builderCreditsUpdated(event);
}
}

View File

@ -1168,4 +1168,243 @@ describe('AI Builder store', () => {
);
});
});
describe('Credits management', () => {
it('should update builder credits correctly', () => {
const builderStore = useBuilderStore();
// Initially undefined
expect(builderStore.creditsQuota).toBeUndefined();
expect(builderStore.creditsRemaining).toBeUndefined();
// Update credits
builderStore.updateBuilderCredits(100, 30);
expect(builderStore.creditsQuota).toBe(100);
expect(builderStore.creditsRemaining).toBe(70);
});
it('should handle unlimited credits (quota = -1)', () => {
const builderStore = useBuilderStore();
builderStore.updateBuilderCredits(-1, 50);
expect(builderStore.creditsQuota).toBe(-1);
expect(builderStore.creditsRemaining).toBeUndefined();
});
it('should handle edge case where claimed > quota', () => {
const builderStore = useBuilderStore();
builderStore.updateBuilderCredits(50, 100);
expect(builderStore.creditsQuota).toBe(50);
expect(builderStore.creditsRemaining).toBe(0);
});
it('should return undefined when credits are not initialized', () => {
const builderStore = useBuilderStore();
expect(builderStore.creditsRemaining).toBeUndefined();
});
it('should return undefined when only quota is set', () => {
const builderStore = useBuilderStore();
builderStore.updateBuilderCredits(100, undefined);
expect(builderStore.creditsRemaining).toBeUndefined();
});
it('should return undefined when only claimed is set', () => {
const builderStore = useBuilderStore();
builderStore.updateBuilderCredits(undefined, 50);
expect(builderStore.creditsRemaining).toBeUndefined();
});
});
describe('hasNoCreditsRemaining', () => {
it('should return false when creditsRemaining is undefined', () => {
const builderStore = useBuilderStore();
// No credits initialized
expect(builderStore.hasNoCreditsRemaining).toBe(false);
});
it('should return true when creditsRemaining is 0', () => {
const builderStore = useBuilderStore();
builderStore.updateBuilderCredits(100, 100);
expect(builderStore.creditsRemaining).toBe(0);
expect(builderStore.hasNoCreditsRemaining).toBe(true);
});
it('should return false when creditsRemaining is greater than 0', () => {
const builderStore = useBuilderStore();
builderStore.updateBuilderCredits(100, 30);
expect(builderStore.creditsRemaining).toBe(70);
expect(builderStore.hasNoCreditsRemaining).toBe(false);
});
it('should return false when quota is undefined', () => {
const builderStore = useBuilderStore();
builderStore.updateBuilderCredits(undefined, 50);
expect(builderStore.creditsRemaining).toBeUndefined();
expect(builderStore.hasNoCreditsRemaining).toBe(false);
});
it('should return false when claimed is undefined', () => {
const builderStore = useBuilderStore();
builderStore.updateBuilderCredits(100, undefined);
expect(builderStore.creditsRemaining).toBeUndefined();
expect(builderStore.hasNoCreditsRemaining).toBe(false);
});
it('should return false when unlimited credits (quota = -1)', () => {
const builderStore = useBuilderStore();
builderStore.updateBuilderCredits(-1, 50);
expect(builderStore.creditsRemaining).toBeUndefined();
expect(builderStore.hasNoCreditsRemaining).toBe(false);
});
it('should return true when claimed exceeds quota', () => {
const builderStore = useBuilderStore();
builderStore.updateBuilderCredits(50, 100);
expect(builderStore.creditsRemaining).toBe(0);
expect(builderStore.hasNoCreditsRemaining).toBe(true);
});
it('should return false when user has credits available', () => {
const builderStore = useBuilderStore();
builderStore.updateBuilderCredits(100, 25);
expect(builderStore.creditsRemaining).toBe(75);
expect(builderStore.hasNoCreditsRemaining).toBe(false);
});
it('should return true immediately after all credits are consumed', () => {
const builderStore = useBuilderStore();
// Start with some credits
builderStore.updateBuilderCredits(100, 99);
expect(builderStore.hasNoCreditsRemaining).toBe(false);
// Consume last credit
builderStore.updateBuilderCredits(100, 100);
expect(builderStore.hasNoCreditsRemaining).toBe(true);
});
});
describe('fetchBuilderCredits', () => {
const mockGetBuilderCredits = vi.spyOn(chatAPI, 'getBuilderCredits');
beforeEach(() => {
mockGetBuilderCredits.mockClear();
});
it('should fetch and update credits when release experiment is variant', async () => {
const builderStore = useBuilderStore();
// Mock posthog to return variant for release experiment
vi.spyOn(posthogStore, 'getVariant').mockImplementation((experimentName) => {
if (experimentName === WORKFLOW_BUILDER_RELEASE_EXPERIMENT.name) {
return WORKFLOW_BUILDER_RELEASE_EXPERIMENT.variant;
}
return WORKFLOW_BUILDER_DEPRECATED_EXPERIMENT.control;
});
// Mock API response
mockGetBuilderCredits.mockResolvedValueOnce({
creditsQuota: 200,
creditsClaimed: 50,
});
await builderStore.fetchBuilderCredits();
expect(mockGetBuilderCredits).toHaveBeenCalled();
expect(builderStore.creditsQuota).toBe(200);
expect(builderStore.creditsRemaining).toBe(150);
});
it('should not fetch credits when release experiment is not variant', async () => {
const builderStore = useBuilderStore();
// Mock posthog to return control for release experiment
vi.spyOn(posthogStore, 'getVariant').mockImplementation((experimentName) => {
if (experimentName === WORKFLOW_BUILDER_RELEASE_EXPERIMENT.name) {
return WORKFLOW_BUILDER_RELEASE_EXPERIMENT.control;
}
return WORKFLOW_BUILDER_DEPRECATED_EXPERIMENT.variant;
});
await builderStore.fetchBuilderCredits();
expect(mockGetBuilderCredits).not.toHaveBeenCalled();
expect(builderStore.creditsQuota).toBeUndefined();
expect(builderStore.creditsRemaining).toBeUndefined();
});
it('should handle API errors gracefully', async () => {
const builderStore = useBuilderStore();
// Mock posthog to return variant for release experiment
vi.spyOn(posthogStore, 'getVariant').mockImplementation((experimentName) => {
if (experimentName === WORKFLOW_BUILDER_RELEASE_EXPERIMENT.name) {
return WORKFLOW_BUILDER_RELEASE_EXPERIMENT.variant;
}
return WORKFLOW_BUILDER_DEPRECATED_EXPERIMENT.control;
});
// Mock API to throw error
mockGetBuilderCredits.mockRejectedValueOnce(new Error('API error'));
await builderStore.fetchBuilderCredits();
expect(mockGetBuilderCredits).toHaveBeenCalled();
// Credits should remain undefined on error
expect(builderStore.creditsQuota).toBeUndefined();
expect(builderStore.creditsRemaining).toBeUndefined();
});
it('should call fetchBuilderCredits when opening chat', async () => {
const builderStore = useBuilderStore();
// Mock posthog to return variant for release experiment
vi.spyOn(posthogStore, 'getVariant').mockImplementation((experimentName) => {
if (experimentName === WORKFLOW_BUILDER_RELEASE_EXPERIMENT.name) {
return WORKFLOW_BUILDER_RELEASE_EXPERIMENT.variant;
}
return WORKFLOW_BUILDER_DEPRECATED_EXPERIMENT.control;
});
// Mock API response
mockGetBuilderCredits.mockResolvedValueOnce({
creditsQuota: 100,
creditsClaimed: 20,
});
// Mock loadSessions to prevent actual API call
vi.spyOn(chatAPI, 'getAiSessions').mockResolvedValueOnce({ sessions: [] });
await builderStore.openChat();
expect(mockGetBuilderCredits).toHaveBeenCalled();
expect(builderStore.creditsQuota).toBe(100);
expect(builderStore.creditsRemaining).toBe(80);
});
});
});

View File

@ -21,7 +21,7 @@ import { usePostHog } from './posthog.store';
import { DEFAULT_CHAT_WIDTH, MAX_CHAT_WIDTH, MIN_CHAT_WIDTH } from './assistant.store';
import { useWorkflowsStore } from './workflows.store';
import { useBuilderMessages } from '@/composables/useBuilderMessages';
import { chatWithBuilder, getAiSessions } from '@/api/ai';
import { chatWithBuilder, getAiSessions, getBuilderCredits } from '@/api/ai';
import { generateMessageId, createBuilderPayload } from '@/helpers/builderHelpers';
import { useRootStore } from '@n8n/stores/useRootStore';
import type { WorkflowDataUpdate } from '@n8n/rest-api-client/api/workflows';
@ -29,6 +29,7 @@ import pick from 'lodash/pick';
import { jsonParse } from 'n8n-workflow';
import { useToast } from '@/composables/useToast';
const INFINITE_CREDITS = -1;
export const ENABLED_VIEWS = [...EDITABLE_CANVAS_VIEWS];
export const useBuilderStore = defineStore(STORES.BUILDER, () => {
@ -40,6 +41,8 @@ export const useBuilderStore = defineStore(STORES.BUILDER, () => {
const assistantThinkingMessage = ref<string | undefined>();
const streamingAbortController = ref<AbortController | null>(null);
const initialGeneration = ref<boolean>(false);
const creditsQuota = ref<number | undefined>();
const creditsClaimed = ref<number | undefined>();
// Store dependencies
const settings = useSettingsStore();
@ -106,6 +109,26 @@ export const useBuilderStore = defineStore(STORES.BUILDER, () => {
chatMessages.value.filter((msg) => msg.role === 'assistant'),
);
const creditsRemaining = computed(() => {
if (
// can be undefined when first loading or if on deprecated builder experiment
creditsClaimed.value === undefined ||
creditsQuota.value === undefined ||
// Can be the case if not using proxy service
creditsQuota.value === INFINITE_CREDITS
) {
return undefined;
}
// some edge cases could lead to claimed being higher than quota
const remaining = creditsQuota.value - creditsClaimed.value;
return remaining > 0 ? remaining : 0;
});
const hasNoCreditsRemaining = computed(() => {
return creditsRemaining.value !== undefined ? creditsRemaining.value === 0 : false;
});
// Chat management functions
/**
* Resets the entire chat session to initial state.
@ -128,6 +151,7 @@ export const useBuilderStore = defineStore(STORES.BUILDER, () => {
...uiStore.appGridDimensions,
width: window.innerWidth - chatWidth.value,
};
await fetchBuilderCredits();
await loadSessions();
}
@ -443,6 +467,27 @@ export const useBuilderStore = defineStore(STORES.BUILDER, () => {
return JSON.stringify(pick(workflowsStore.workflow, ['nodes', 'connections']));
}
async function fetchBuilderCredits() {
const releaseExperimentVariant = posthogStore.getVariant(
WORKFLOW_BUILDER_RELEASE_EXPERIMENT.name,
);
if (releaseExperimentVariant !== WORKFLOW_BUILDER_RELEASE_EXPERIMENT.variant) {
return;
}
try {
const response = await getBuilderCredits(rootStore.restApiContext);
updateBuilderCredits(response.creditsQuota, response.creditsClaimed);
} catch (error) {
// Keep default values on error
}
}
function updateBuilderCredits(quota?: number, claimed?: number) {
creditsQuota.value = quota;
creditsClaimed.value = claimed;
}
// Public API
return {
// State
@ -463,6 +508,9 @@ export const useBuilderStore = defineStore(STORES.BUILDER, () => {
trackingSessionId,
streamingAbortController,
initialGeneration,
creditsQuota: computed(() => creditsQuota.value),
creditsRemaining,
hasNoCreditsRemaining,
// Methods
updateWindowWidth,
@ -474,5 +522,7 @@ export const useBuilderStore = defineStore(STORES.BUILDER, () => {
loadSessions,
applyWorkflowUpdate,
getWorkflowSnapshot,
fetchBuilderCredits,
updateBuilderCredits,
};
});

View File

@ -431,6 +431,10 @@ async function initializeRoute(force = false) {
if (!isDemoRoute.value) {
await loadCredentials();
// Fetch builder credits when initializing the route
// Only needed if workflow is editable where builder can be used
void builderStore.fetchBuilderCredits();
}
// If there is no workflow id, treat it as a new workflow