From 249d8f6ee6bfc7b5b31141bd4b40184df64781c3 Mon Sep 17 00:00:00 2001 From: Mutasem Aldmour <4711238+mutdmour@users.noreply.github.com> Date: Fri, 26 Sep 2025 15:01:56 +0200 Subject: [PATCH] feat: Add metering UI (no-changelog) (#20021) Co-authored-by: Michael Drury --- .../AskAssistantChat/AskAssistantChat.test.ts | 166 +- .../AskAssistantChat/AskAssistantChat.vue | 8 + .../AskAssistantChat.test.ts.snap | 2180 ++--------------- .../N8nPromptInput/N8nPromptInput.stories.ts | 97 + .../N8nPromptInput/N8nPromptInput.test.ts | 452 +++- .../N8nPromptInput/N8nPromptInput.vue | 293 ++- .../__snapshots__/N8nPromptInput.test.ts.snap | 180 +- .../@n8n/design-system/src/locale/lang/en.ts | 8 + packages/frontend/editor-ui/src/Interface.ts | 7 +- .../frontend/editor-ui/src/api/ai.test.ts | 42 +- packages/frontend/editor-ui/src/api/ai.ts | 7 + .../Agent/AskAssistantBuild.test.ts | 212 +- .../AskAssistant/Agent/AskAssistantBuild.vue | 9 + .../render-types/CanvasNodeAIPrompt.test.ts | 137 ++ .../nodes/render-types/CanvasNodeAIPrompt.vue | 21 +- .../CanvasNodeAIPrompt.test.ts.snap | 35 +- .../handlers/builderCreditsUpdated.test.ts | 30 + .../handlers/builderCreditsUpdated.ts | 9 + .../usePushConnection/handlers/index.ts | 1 + .../usePushConnection.test.ts | 33 +- .../usePushConnection/usePushConnection.ts | 3 + .../src/stores/builder.store.test.ts | 239 ++ .../editor-ui/src/stores/builder.store.ts | 52 +- .../frontend/editor-ui/src/views/NodeView.vue | 4 + 24 files changed, 1966 insertions(+), 2259 deletions(-) create mode 100644 packages/frontend/editor-ui/src/composables/usePushConnection/handlers/builderCreditsUpdated.test.ts create mode 100644 packages/frontend/editor-ui/src/composables/usePushConnection/handlers/builderCreditsUpdated.ts diff --git a/packages/frontend/@n8n/design-system/src/components/AskAssistantChat/AskAssistantChat.test.ts b/packages/frontend/@n8n/design-system/src/components/AskAssistantChat/AskAssistantChat.test.ts index 3d9eb38e5d8..94664e142af 100644 --- a/packages/frontend/@n8n/design-system/src/components/AskAssistantChat/AskAssistantChat.test.ts +++ b/packages/frontend/@n8n/design-system/src/components/AskAssistantChat/AskAssistantChat.test.ts @@ -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(); + 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: '
{{ JSON.stringify(message) }}
', +}; + +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: '
', })); - 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: ' - +
-
+
Start manuallyAdd the first node
" diff --git a/packages/frontend/editor-ui/src/composables/usePushConnection/handlers/builderCreditsUpdated.test.ts b/packages/frontend/editor-ui/src/composables/usePushConnection/handlers/builderCreditsUpdated.test.ts new file mode 100644 index 00000000000..d667c73b153 --- /dev/null +++ b/packages/frontend/editor-ui/src/composables/usePushConnection/handlers/builderCreditsUpdated.test.ts @@ -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; + + vi.mocked(useBuilderStore).mockReturnValue(mockStore); + + const event: BuilderCreditsPushMessage = { + type: 'updateBuilderCredits', + data: { + creditsQuota: 1000, + creditsClaimed: 250, + }, + }; + + await builderCreditsUpdated(event); + + expect(mockUpdateBuilderCredits).toHaveBeenCalledWith(1000, 250); + }); +}); diff --git a/packages/frontend/editor-ui/src/composables/usePushConnection/handlers/builderCreditsUpdated.ts b/packages/frontend/editor-ui/src/composables/usePushConnection/handlers/builderCreditsUpdated.ts new file mode 100644 index 00000000000..e29a3cef224 --- /dev/null +++ b/packages/frontend/editor-ui/src/composables/usePushConnection/handlers/builderCreditsUpdated.ts @@ -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 { + const builderStore = useBuilderStore(); + + // Update the builder store with new credits values + builderStore.updateBuilderCredits(event.data.creditsQuota, event.data.creditsClaimed); +} diff --git a/packages/frontend/editor-ui/src/composables/usePushConnection/handlers/index.ts b/packages/frontend/editor-ui/src/composables/usePushConnection/handlers/index.ts index 9b08523c1e3..1c7b6788055 100644 --- a/packages/frontend/editor-ui/src/composables/usePushConnection/handlers/index.ts +++ b/packages/frontend/editor-ui/src/composables/usePushConnection/handlers/index.ts @@ -1,3 +1,4 @@ +export * from './builderCreditsUpdated'; export * from './executionFinished'; export * from './executionRecovered'; export * from './executionStarted'; diff --git a/packages/frontend/editor-ui/src/composables/usePushConnection/usePushConnection.test.ts b/packages/frontend/editor-ui/src/composables/usePushConnection/usePushConnection.test.ts index cc7c272d365..c335b5727d2 100644 --- a/packages/frontend/editor-ui/src/composables/usePushConnection/usePushConnection.test.ts +++ b/packages/frontend/editor-ui/src/composables/usePushConnection/usePushConnection.test.ts @@ -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); + }); }); diff --git a/packages/frontend/editor-ui/src/composables/usePushConnection/usePushConnection.ts b/packages/frontend/editor-ui/src/composables/usePushConnection/usePushConnection.ts index cdff1cd983f..bf8d8f66cc7 100644 --- a/packages/frontend/editor-ui/src/composables/usePushConnection/usePushConnection.ts +++ b/packages/frontend/editor-ui/src/composables/usePushConnection/usePushConnection.ts @@ -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 { ); }); }); + + 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); + }); + }); }); diff --git a/packages/frontend/editor-ui/src/stores/builder.store.ts b/packages/frontend/editor-ui/src/stores/builder.store.ts index 92ac5b76c99..4d6f5fcd53a 100644 --- a/packages/frontend/editor-ui/src/stores/builder.store.ts +++ b/packages/frontend/editor-ui/src/stores/builder.store.ts @@ -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(); const streamingAbortController = ref(null); const initialGeneration = ref(false); + const creditsQuota = ref(); + const creditsClaimed = ref(); // 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, }; }); diff --git a/packages/frontend/editor-ui/src/views/NodeView.vue b/packages/frontend/editor-ui/src/views/NodeView.vue index e1298ef89c6..9c255cdf3e8 100644 --- a/packages/frontend/editor-ui/src/views/NodeView.vue +++ b/packages/frontend/editor-ui/src/views/NodeView.vue @@ -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