mirror of
https://github.com/n8n-io/n8n.git
synced 2026-05-31 08:46:58 +02:00
feat: Add metering UI (no-changelog) (#20021)
Co-authored-by: Michael Drury <michael.drury@n8n.io>
This commit is contained in:
parent
3963e97c1f
commit
249d8f6ee6
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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')"
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
`;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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]: {
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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 */
|
||||
|
|
|
|||
|
|
@ -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>"
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
export * from './builderCreditsUpdated';
|
||||
export * from './executionFinished';
|
||||
export * from './executionRecovered';
|
||||
export * from './executionStarted';
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user