From 516c19e2eda86f132d9d025711fdcaf9cc19e63c Mon Sep 17 00:00:00 2001 From: Alexander Gekov <40495748+alexander-gekov@users.noreply.github.com> Date: Mon, 17 Aug 2026 12:06:26 +0000 Subject: [PATCH] feat(editor): Switch credits pill after a top-up or depleted allowance (#36239) Co-authored-by: Cursor --- .../dto/ai/ai-gateway-wallet-response.dto.ts | 5 ++ packages/@n8n/api-types/src/dto/index.ts | 1 + .../__tests__/ai.controller.test.ts | 2 +- packages/cli/src/controllers/ai.controller.ts | 8 ++- .../__tests__/ai-gateway.service.test.ts | 15 ++++- .../cli/src/services/ai-gateway.service.ts | 18 +++--- .../N8nActionPill/ActionPill.stories.ts | 40 ++++++++++++ .../N8nActionPill/ActionPill.test.ts | 15 +++++ .../components/N8nActionPill/ActionPill.vue | 14 ++++- .../__snapshots__/ActionPill.test.ts.snap | 12 ++++ .../AiModelSelectorDropdown.types.ts | 2 +- .../NodeCreatorNode.test.ts | 29 +++++++++ .../N8nNodeCreatorNode/NodeCreatorNode.vue | 7 ++- .../frontend/@n8n/i18n/src/locales/en.json | 1 + .../src/app/composables/useAiGateway.test.ts | 6 +- .../src/app/composables/useAiGateway.ts | 2 + .../src/app/stores/aiGateway.store.test.ts | 62 ++++++++++++++++++- .../src/app/stores/aiGateway.store.ts | 41 +++++++++--- .../agents/__tests__/AgentBuilderView.test.ts | 15 ++--- .../__tests__/AgentModelSelector.spec.ts | 17 +++++ .../agents/components/AgentModelSelector.vue | 8 ++- .../features/ai/assistant/assistant.api.ts | 11 ++-- .../components/NodeCredentials.test.ts | 14 +++++ .../components/ParameterInputList.test.ts | 9 +++ .../nodeCreator/nodeCreator.store.test.ts | 6 +- .../shared/nodeCreator/nodeCreator.store.ts | 7 ++- .../nodeCreator/nodeCreator.utils.test.ts | 34 ++++++++++ .../shared/nodeCreator/nodeCreator.utils.ts | 23 ++++--- 28 files changed, 366 insertions(+), 58 deletions(-) create mode 100644 packages/@n8n/api-types/src/dto/ai/ai-gateway-wallet-response.dto.ts create mode 100644 packages/frontend/@n8n/design-system/src/components/N8nActionPill/ActionPill.stories.ts create mode 100644 packages/frontend/@n8n/design-system/src/components/N8nNodeCreatorNode/NodeCreatorNode.test.ts diff --git a/packages/@n8n/api-types/src/dto/ai/ai-gateway-wallet-response.dto.ts b/packages/@n8n/api-types/src/dto/ai/ai-gateway-wallet-response.dto.ts new file mode 100644 index 00000000000..fa44ef5695e --- /dev/null +++ b/packages/@n8n/api-types/src/dto/ai/ai-gateway-wallet-response.dto.ts @@ -0,0 +1,5 @@ +export interface AiGatewayWalletResponse { + budget: number; + balance: number; + hasEverToppedUp: boolean; +} diff --git a/packages/@n8n/api-types/src/dto/index.ts b/packages/@n8n/api-types/src/dto/index.ts index 219af08e88d..60abfd85423 100644 --- a/packages/@n8n/api-types/src/dto/index.ts +++ b/packages/@n8n/api-types/src/dto/index.ts @@ -24,6 +24,7 @@ export type { AiGatewayUsageMetadata, AiGatewayUsageResponse, } from './ai/ai-gateway-usage-response.dto'; +export type { AiGatewayWalletResponse } from './ai/ai-gateway-wallet-response.dto'; export { InstanceAiConfirmRequestDto, diff --git a/packages/cli/src/controllers/__tests__/ai.controller.test.ts b/packages/cli/src/controllers/__tests__/ai.controller.test.ts index 975cd5f5055..c9c4b936fe6 100644 --- a/packages/cli/src/controllers/__tests__/ai.controller.test.ts +++ b/packages/cli/src/controllers/__tests__/ai.controller.test.ts @@ -673,7 +673,7 @@ describe('AiController', () => { }); it('should return wallet from aiGatewayService', async () => { - const walletData = { budget: 10, balance: 7 }; + const walletData = { budget: 10, balance: 7, hasEverToppedUp: false }; aiGatewayService.getWallet.mockResolvedValue(walletData); const result = await controller.getGatewayWallet(request); diff --git a/packages/cli/src/controllers/ai.controller.ts b/packages/cli/src/controllers/ai.controller.ts index 68ec607b594..d5147590713 100644 --- a/packages/cli/src/controllers/ai.controller.ts +++ b/packages/cli/src/controllers/ai.controller.ts @@ -1,4 +1,8 @@ -import type { AiGatewayConfigDto, AiGatewayUsageResponse } from '@n8n/api-types'; +import type { + AiGatewayConfigDto, + AiGatewayUsageResponse, + AiGatewayWalletResponse, +} from '@n8n/api-types'; import { AiChatRequestDto, AiApplySuggestionRequestDto, @@ -259,7 +263,7 @@ export class AiController { @Licensed('feat:aiGateway') @Get('/gateway/wallet') - async getGatewayWallet(req: AuthenticatedRequest): Promise<{ budget: number; balance: number }> { + async getGatewayWallet(req: AuthenticatedRequest): Promise { this.aiGatewayService.assertEnabled(); try { return await this.aiGatewayService.getWallet(req.user.id); diff --git a/packages/cli/src/services/__tests__/ai-gateway.service.test.ts b/packages/cli/src/services/__tests__/ai-gateway.service.test.ts index 79b6a96cbbe..f198226b6e8 100644 --- a/packages/cli/src/services/__tests__/ai-gateway.service.test.ts +++ b/packages/cli/src/services/__tests__/ai-gateway.service.test.ts @@ -722,7 +722,18 @@ describe('AiGatewayService', () => { await expect(service.getWallet(USER_ID)).rejects.toThrow(UserError); }); - it('returns budget and balance from gateway wallet', async () => { + it('returns budget, balance, and hasEverToppedUp from gateway wallet', async () => { + requestMock + .mockResolvedValueOnce(ok({ token: 'mock-jwt', expiresIn: 3600 })) + .mockResolvedValueOnce(ok({ budget: 10, balance: 7, hasEverToppedUp: true })); + const service = makeService(); + + const result = await service.getWallet(USER_ID); + + expect(result).toEqual({ budget: 10, balance: 7, hasEverToppedUp: true }); + }); + + it('defaults hasEverToppedUp to false when the gateway omits it', async () => { requestMock .mockResolvedValueOnce(ok({ token: 'mock-jwt', expiresIn: 3600 })) .mockResolvedValueOnce(ok({ budget: 10, balance: 7 })); @@ -730,7 +741,7 @@ describe('AiGatewayService', () => { const result = await service.getWallet(USER_ID); - expect(result).toEqual({ budget: 10, balance: 7 }); + expect(result).toEqual({ budget: 10, balance: 7, hasEverToppedUp: false }); }); it('sends JWT Bearer token in Authorization header to credits endpoint', async () => { diff --git a/packages/cli/src/services/ai-gateway.service.ts b/packages/cli/src/services/ai-gateway.service.ts index ddb1ad47e12..6a3eaef856c 100644 --- a/packages/cli/src/services/ai-gateway.service.ts +++ b/packages/cli/src/services/ai-gateway.service.ts @@ -2,6 +2,7 @@ import { AiGatewayConfigDto, getAgentModelProviderCredentialTypes, type AiGatewayUsageResponse, + type AiGatewayWalletResponse, } from '@n8n/api-types'; import { LicenseState } from '@n8n/backend-common'; import { OutboundHttp } from '@n8n/backend-network'; @@ -25,11 +26,6 @@ interface GatewayTokenResponse { expiresIn: number; } -interface GatewayWalletResponse { - budget: number; - balance: number; -} - export type AiGatewayAvailability = | { available: true; config: AiGatewayConfigDto } | { available: false }; @@ -282,7 +278,7 @@ export class AiGatewayService { /** * Returns the current wallet (budget and remaining balance) for the given user. */ - async getWallet(userId: string): Promise { + async getWallet(userId: string): Promise { const baseUrl = this.requireBaseUrl(); const jwt = await this.getOrFetchToken(userId); @@ -301,12 +297,16 @@ export class AiGatewayService { return this.parseWalletResponse(data); } - private parseWalletResponse(data: unknown): GatewayWalletResponse { - const d = data as GatewayWalletResponse; + private parseWalletResponse(data: unknown): AiGatewayWalletResponse { + const d = data as { budget?: unknown; balance?: unknown; hasEverToppedUp?: unknown }; if (typeof d.budget !== 'number' || typeof d.balance !== 'number') { throw new UserError('n8n credits returned an invalid wallet response.'); } - return d; + return { + budget: d.budget, + balance: d.balance, + hasEverToppedUp: d.hasEverToppedUp === true, + }; } /** diff --git a/packages/frontend/@n8n/design-system/src/components/N8nActionPill/ActionPill.stories.ts b/packages/frontend/@n8n/design-system/src/components/N8nActionPill/ActionPill.stories.ts new file mode 100644 index 00000000000..8760922d245 --- /dev/null +++ b/packages/frontend/@n8n/design-system/src/components/N8nActionPill/ActionPill.stories.ts @@ -0,0 +1,40 @@ +import type { Meta, StoryObj } from '@storybook/vue3-vite'; + +import N8nActionPill from './ActionPill.vue'; + +const meta = { + title: 'Core/Action Pill', + component: N8nActionPill, + argTypes: { + type: { + control: 'select', + options: ['default', 'info', 'danger'], + }, + size: { + control: 'select', + options: ['small', 'medium'], + }, + }, + args: { + text: 'Free credits', + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +export const Info: Story = { + args: { + text: 'n8n credits', + type: 'info', + }, +}; + +export const Danger: Story = { + args: { + text: 'No credits', + type: 'danger', + }, +}; diff --git a/packages/frontend/@n8n/design-system/src/components/N8nActionPill/ActionPill.test.ts b/packages/frontend/@n8n/design-system/src/components/N8nActionPill/ActionPill.test.ts index 01b6ef5c534..a14e3415e33 100644 --- a/packages/frontend/@n8n/design-system/src/components/N8nActionPill/ActionPill.test.ts +++ b/packages/frontend/@n8n/design-system/src/components/N8nActionPill/ActionPill.test.ts @@ -1,6 +1,7 @@ import { render, fireEvent } from '@testing-library/vue'; import { describe, it, expect } from 'vitest'; +import * as stories from './ActionPill.stories'; import ActionPill from './ActionPill.vue'; describe('N8nActionPill', () => { @@ -45,6 +46,20 @@ describe('N8nActionPill', () => { expect(container).toMatchSnapshot(); }); + it('renders info type correctly', () => { + const { container } = render(ActionPill, { + props: { text: 'n8n credits', type: 'info' }, + }); + expect(container).toMatchSnapshot(); + }); + + it('documents default, info, and danger variants in Storybook', () => { + expect(stories.default.title).toBe('Core/Action Pill'); + expect(stories.default.argTypes?.type.options).toEqual(['default', 'info', 'danger']); + expect(stories.Info.args).toEqual({ text: 'n8n credits', type: 'info' }); + expect(stories.Danger.args).toEqual({ text: 'No credits', type: 'danger' }); + }); + it('renders small size correctly', () => { const { container } = render(ActionPill, { props: { text: 'Label', size: 'small' }, diff --git a/packages/frontend/@n8n/design-system/src/components/N8nActionPill/ActionPill.vue b/packages/frontend/@n8n/design-system/src/components/N8nActionPill/ActionPill.vue index d04add3a91e..afafa9ad8e0 100644 --- a/packages/frontend/@n8n/design-system/src/components/N8nActionPill/ActionPill.vue +++ b/packages/frontend/@n8n/design-system/src/components/N8nActionPill/ActionPill.vue @@ -17,8 +17,8 @@ withDefaults( clickable?: boolean; /** 'small' matches PreviewTag scale (for menus/lists); 'medium' is the default. */ size?: 'small' | 'medium'; - /** Visual variant. 'default' is green; 'danger' is red for depleted/error states. */ - type?: 'default' | 'danger'; + /** Visual variant. */ + type?: 'default' | 'danger' | 'info'; }>(), { text: undefined, @@ -42,6 +42,7 @@ const hovered = ref(false); $style.root, size === 'small' && $style.small, type === 'danger' && $style.danger, + type === 'info' && $style.info, clickable && $style.clickable, hoverText && hovered && $style.pressed, ]" @@ -91,6 +92,15 @@ const hovered = ref(false); } } +.info { + background-color: var(--color--blue-50); + color: var(--color--blue-800); + + &.clickable:hover { + background-color: color-mix(in srgb, var(--color--blue-50), var(--color--neutral-black) 5%); + } +} + .clickable { cursor: pointer; user-select: none; diff --git a/packages/frontend/@n8n/design-system/src/components/N8nActionPill/__snapshots__/ActionPill.test.ts.snap b/packages/frontend/@n8n/design-system/src/components/N8nActionPill/__snapshots__/ActionPill.test.ts.snap index f415c025277..b65d634eb5c 100644 --- a/packages/frontend/@n8n/design-system/src/components/N8nActionPill/__snapshots__/ActionPill.test.ts.snap +++ b/packages/frontend/@n8n/design-system/src/components/N8nActionPill/__snapshots__/ActionPill.test.ts.snap @@ -61,6 +61,18 @@ exports[`N8nActionPill > snapshots > renders default type correctly 1`] = ` `; +exports[`N8nActionPill > snapshots > renders info type correctly 1`] = ` +
+ + + n8n credits + + +
+`; + exports[`N8nActionPill > snapshots > renders small size correctly 1`] = `
{ + it('passes info type to the credits pill', () => { + const { getByText } = renderNode({ text: 'n8n credits', pill: true, type: 'info' }); + expect(getByText('n8n credits').className).toContain('info'); + }); + + it('passes danger type to the credits pill', () => { + const { getByText } = renderNode({ text: 'No credits', pill: true, type: 'danger' }); + expect(getByText('No credits').className).toContain('danger'); + }); + + it('defaults the credits pill type when tag type is not info or danger', () => { + const { getByText } = renderNode({ text: 'Free credits', pill: true, type: 'success' }); + const className = getByText('Free credits').className; + expect(className).not.toContain('info'); + expect(className).not.toContain('danger'); + }); +}); diff --git a/packages/frontend/@n8n/design-system/src/components/N8nNodeCreatorNode/NodeCreatorNode.vue b/packages/frontend/@n8n/design-system/src/components/N8nNodeCreatorNode/NodeCreatorNode.vue index 04682a60242..e9bdc966783 100644 --- a/packages/frontend/@n8n/design-system/src/components/N8nNodeCreatorNode/NodeCreatorNode.vue +++ b/packages/frontend/@n8n/design-system/src/components/N8nNodeCreatorNode/NodeCreatorNode.vue @@ -47,7 +47,12 @@ const { t } = useI18n();
- + { it('should fetch and update balance and budget when enabled', async () => { mockIsAiGatewayEnabled.value = true; - mockGetGatewayWallet.mockResolvedValue({ balance: 7, budget: 10 }); + mockGetGatewayWallet.mockResolvedValue({ + balance: 7, + budget: 10, + hasEverToppedUp: false, + }); const { fetchWallet, balance, budget } = useAiGateway(); diff --git a/packages/frontend/editor-ui/src/app/composables/useAiGateway.ts b/packages/frontend/editor-ui/src/app/composables/useAiGateway.ts index 377d703a459..539e0d44efa 100644 --- a/packages/frontend/editor-ui/src/app/composables/useAiGateway.ts +++ b/packages/frontend/editor-ui/src/app/composables/useAiGateway.ts @@ -13,6 +13,7 @@ export function useAiGateway() { const balance = computed(() => aiGatewayStore.balance); const budget = computed(() => aiGatewayStore.budget); + const creditsLabelKey = computed(() => aiGatewayStore.creditsLabelKey); const fetchError = computed(() => aiGatewayStore.fetchError); const isEnabled = computed(() => settingsStore.isAiGatewayEnabled); @@ -59,6 +60,7 @@ export function useAiGateway() { isEnabled, balance, budget, + creditsLabelKey, fetchError, fetchConfig, fetchWallet, diff --git a/packages/frontend/editor-ui/src/app/stores/aiGateway.store.test.ts b/packages/frontend/editor-ui/src/app/stores/aiGateway.store.test.ts index f04ce92f54b..d9096003852 100644 --- a/packages/frontend/editor-ui/src/app/stores/aiGateway.store.test.ts +++ b/packages/frontend/editor-ui/src/app/stores/aiGateway.store.test.ts @@ -1,7 +1,7 @@ import { createPinia, setActivePinia } from 'pinia'; import { describe, it, vi, beforeEach, expect } from 'vitest'; import type { INode } from 'n8n-workflow'; -import { useAiGatewayStore } from './aiGateway.store'; +import { useAiGatewayStore, usesFreeCreditsLabel } from './aiGateway.store'; const mockGetGatewayConfig = vi.fn(); const mockGetGatewayWallet = vi.fn(); @@ -124,14 +124,19 @@ describe('aiGateway.store', () => { }); describe('fetchWallet()', () => { - it('should update balance and budget', async () => { - mockGetGatewayWallet.mockResolvedValue({ balance: 7, budget: 10 }); + it('should update balance, budget, and hasEverToppedUp', async () => { + mockGetGatewayWallet.mockResolvedValue({ + balance: 7, + budget: 10, + hasEverToppedUp: true, + }); const store = useAiGatewayStore(); await store.fetchWallet(); expect(store.balance).toBe(7); expect(store.budget).toBe(10); + expect(store.hasEverToppedUp).toBe(true); expect(store.fetchError).toBeNull(); }); @@ -157,6 +162,57 @@ describe('aiGateway.store', () => { expect(store.fetchError).toBeNull(); expect(store.balance).toBe(3); }); + + it('shares an in-flight wallet fetch', async () => { + let resolveWallet!: (value: { + balance: number; + budget: number; + hasEverToppedUp: boolean; + }) => void; + mockGetGatewayWallet.mockReturnValue( + new Promise((resolve) => { + resolveWallet = resolve; + }), + ); + const store = useAiGatewayStore(); + + const first = store.fetchWallet(); + const second = store.fetchWallet(); + resolveWallet({ balance: 1, budget: 2, hasEverToppedUp: false }); + await Promise.all([first, second]); + + expect(mockGetGatewayWallet).toHaveBeenCalledOnce(); + expect(store.balance).toBe(1); + }); + }); + + describe('usesFreeCreditsLabel()', () => { + it.each([ + { hasEverToppedUp: false, balance: 1.26, expected: true }, + { hasEverToppedUp: true, balance: 1.26, expected: false }, + { hasEverToppedUp: false, balance: 0, expected: false }, + { hasEverToppedUp: true, balance: 0, expected: false }, + { hasEverToppedUp: undefined, balance: undefined, expected: true }, + ])( + 'hasEverToppedUp=$hasEverToppedUp balance=$balance → $expected', + ({ hasEverToppedUp, balance, expected }) => { + expect(usesFreeCreditsLabel(hasEverToppedUp, balance)).toBe(expected); + }, + ); + + it('drives creditsLabelKey from the fetched wallet', async () => { + mockGetGatewayWallet.mockResolvedValue({ + balance: 5, + budget: 10, + hasEverToppedUp: true, + }); + const store = useAiGatewayStore(); + expect(store.creditsLabelKey).toBe('generic.freeCredits'); + + await store.fetchWallet(); + + expect(store.creditsLabelKey).toBe('generic.n8nCredits'); + }); }); describe('fetchUsage()', () => { diff --git a/packages/frontend/editor-ui/src/app/stores/aiGateway.store.ts b/packages/frontend/editor-ui/src/app/stores/aiGateway.store.ts index ce09d3f4077..eb2d660aeee 100644 --- a/packages/frontend/editor-ui/src/app/stores/aiGateway.store.ts +++ b/packages/frontend/editor-ui/src/app/stores/aiGateway.store.ts @@ -1,4 +1,4 @@ -import { ref } from 'vue'; +import { computed, ref } from 'vue'; import { defineStore } from 'pinia'; import type { INode } from 'n8n-workflow'; import type { AiGatewayConfigDto, AiGatewayUsageEntry } from '@n8n/api-types'; @@ -22,12 +22,26 @@ function toError(e: unknown): Error { export const stripToolSuffix = (nodeName: string) => nodeName.replace(/HitlTool$/, '').replace(/Tool$/, ''); +/** Free credits until we know they topped up or the balance is gone. */ +export function usesFreeCreditsLabel( + hasEverToppedUp: boolean | undefined, + balance: number | undefined, +): boolean { + return hasEverToppedUp !== true && (balance === undefined || balance > 0); +} + export const useAiGatewayStore = defineStore(STORES.AI_GATEWAY, () => { const rootStore = useRootStore(); const config = ref(null); const balance = ref(undefined); const budget = ref(undefined); + const hasEverToppedUp = ref(undefined); + const creditsLabelKey = computed((): 'generic.freeCredits' | 'generic.n8nCredits' => + usesFreeCreditsLabel(hasEverToppedUp.value, balance.value) + ? 'generic.freeCredits' + : 'generic.n8nCredits', + ); const usageEntries = ref([]); const usageTotal = ref(0); const fetchError = ref(null); @@ -35,6 +49,7 @@ export const useAiGatewayStore = defineStore(STORES.AI_GATEWAY, () => { // Every model selector fetches on mount, so several can be in flight before the // first response lands. Share the promise rather than firing one request each. let configFetch: Promise | null = null; + let walletFetch: Promise | null = null; async function fetchConfig(): Promise { if (config.value !== null) return; @@ -52,14 +67,20 @@ export const useAiGatewayStore = defineStore(STORES.AI_GATEWAY, () => { } async function fetchWallet(): Promise { - try { - const data = await getGatewayWallet(rootStore.restApiContext); - balance.value = data.balance; - budget.value = data.budget; - fetchError.value = null; - } catch (error) { - fetchError.value = toError(error); - } + walletFetch ??= (async () => { + try { + const data = await getGatewayWallet(rootStore.restApiContext); + balance.value = data.balance; + budget.value = data.budget; + hasEverToppedUp.value = data.hasEverToppedUp; + fetchError.value = null; + } catch (error) { + fetchError.value = toError(error); + } finally { + walletFetch = null; + } + })(); + await walletFetch; } async function fetchUsage(offset = 0, limit = 50): Promise { @@ -189,6 +210,8 @@ export const useAiGatewayStore = defineStore(STORES.AI_GATEWAY, () => { config, balance, budget, + hasEverToppedUp, + creditsLabelKey, usageEntries, usageTotal, fetchError, diff --git a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentBuilderView.test.ts b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentBuilderView.test.ts index 019c47f2895..42f27ab2105 100644 --- a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentBuilderView.test.ts +++ b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentBuilderView.test.ts @@ -1607,18 +1607,15 @@ describe('AgentBuilderView — preview routing', { timeout: 60_000 }, () => { const editorColumn = wrapper.findComponent({ name: 'AgentBuilderEditorColumn' }); expect(editorColumn.props('agent')).toBeTruthy(); - vi.useFakeTimers(); - try { - editorColumn.vm.$emit('toggle-mcp-access', true); - await nextTick(); - await vi.runOnlyPendingTimersAsync(); - expect(toggleAgentMcpAccess).toHaveBeenCalledExactlyOnceWith('a2', true); - } finally { - vi.useRealTimers(); - } + editorColumn.vm.$emit('toggle-mcp-access', true); + await nextTick(); + // Switching agents flushes the pending MCP debounce — same path as + // production, and not dependent on fake timers + nextTick. await wrapper.setProps({ artifactAgentId: 'a3', artifactAgentPending: true }); await flushPromises(); + expect(toggleAgentMcpAccess).toHaveBeenCalledExactlyOnceWith('a2', true); + await wrapper.setProps({ artifactAgentPending: false }); mcpSave.resolve({ updatedCount: 1, updatedIds: ['a2'], unchangedIds: [] }); await flushPromises(); diff --git a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentModelSelector.spec.ts b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentModelSelector.spec.ts index 0ec5b602f52..8d9fa656013 100644 --- a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentModelSelector.spec.ts +++ b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentModelSelector.spec.ts @@ -46,6 +46,7 @@ const aiGatewayState = vi.hoisted(() => ({ balance: { value: undefined as number | undefined }, fetchWallet: vi.fn(), fetchConfig: vi.fn(), + creditsLabelKey: { value: 'generic.freeCredits' as 'generic.freeCredits' | 'generic.n8nCredits' }, })); const baseText = vi.hoisted(() => vi.fn((key: string, options?: { interpolate?: Record }) => { @@ -56,6 +57,7 @@ const baseText = vi.hoisted(() => 'agents.modelSelector.connectTo': 'Connect to {provider}', 'agents.modelSelector.models': 'Models', 'generic.freeCredits': 'Free credits', + 'generic.n8nCredits': 'n8n credits', 'agents.modelSelector.credentialsMissing': 'Credentials missing', 'agents.modelSelector.noMatch': 'No match', 'agents.modelSelector.noModels': 'No models', @@ -139,6 +141,7 @@ vi.mock('@/app/composables/useAiGateway', () => ({ isCredentialTypeSupported: (type: string) => aiGatewayState.supportedTypes.has(type), canServeCredentialType: (type: string) => aiGatewayState.supportedTypes.has(type), balance: aiGatewayState.balance, + creditsLabelKey: aiGatewayState.creditsLabelKey, fetchWallet: aiGatewayState.fetchWallet, fetchConfig: aiGatewayState.fetchConfig, }), @@ -232,6 +235,7 @@ describe('AgentModelSelector', () => { aiGatewayState.isEnabled.value = false; aiGatewayState.supportedTypes = new Set(); aiGatewayState.balance.value = undefined; + aiGatewayState.creditsLabelKey.value = 'generic.freeCredits'; aiGatewayState.fetchWallet.mockReset(); aiGatewayState.fetchConfig.mockReset(); }); @@ -269,6 +273,19 @@ describe('AgentModelSelector', () => { }); }); + it('shows the n8n credits pill on covered providers after a top-up', async () => { + aiGatewayState.isEnabled.value = true; + aiGatewayState.supportedTypes = new Set(['anthropicApi']); + aiGatewayState.creditsLabelKey.value = 'generic.n8nCredits'; + + const wrapper = await mountSelector({ anthropic: null }); + + expect(getProviderItem(wrapper, 'anthropic')?.data?.actionPill).toEqual({ + text: 'n8n credits', + type: 'info', + }); + }); + it('does not show the Free credits pill on unsupported providers', async () => { aiGatewayState.isEnabled.value = true; aiGatewayState.supportedTypes = new Set(); diff --git a/packages/frontend/editor-ui/src/features/agents/components/AgentModelSelector.vue b/packages/frontend/editor-ui/src/features/agents/components/AgentModelSelector.vue index b46af4ae241..ba778fc2986 100644 --- a/packages/frontend/editor-ui/src/features/agents/components/AgentModelSelector.vue +++ b/packages/frontend/editor-ui/src/features/agents/components/AgentModelSelector.vue @@ -386,7 +386,13 @@ function providerToMenuItem(provider: AgentModelProvider): MenuItem { ? i18n.baseText('agents.modelSelector.freeCredits.badge') : undefined, actionPill: isAiGatewayManagedAvailable - ? { text: i18n.baseText('generic.freeCredits'), type: 'default' as const } + ? { + text: i18n.baseText(aiGateway.creditsLabelKey.value), + type: + aiGateway.creditsLabelKey.value === 'generic.freeCredits' + ? ('default' as const) + : ('info' as const), + } : undefined, }, children: [...connectHeader, ...connectItems, ...modelsHeader, ...modelsSection], diff --git a/packages/frontend/editor-ui/src/features/ai/assistant/assistant.api.ts b/packages/frontend/editor-ui/src/features/ai/assistant/assistant.api.ts index ed9530570ae..3daeecada85 100644 --- a/packages/frontend/editor-ui/src/features/ai/assistant/assistant.api.ts +++ b/packages/frontend/editor-ui/src/features/ai/assistant/assistant.api.ts @@ -9,7 +9,11 @@ import type { } from '@/features/ai/assistant/assistant.types'; import { makeRestApiRequest, streamRequest } from '@n8n/rest-api-client'; import { getObjectSizeInKB } from '@/app/utils/objectUtils'; -import type { AiGatewayConfigDto, AiGatewayUsageResponse } from '@n8n/api-types'; +import type { + AiGatewayConfigDto, + AiGatewayUsageResponse, + AiGatewayWalletResponse, +} from '@n8n/api-types'; import type { IDataObject } from 'n8n-workflow'; export function chatWithBuilder( @@ -133,10 +137,7 @@ export async function getGatewayConfig(ctx: IRestApiContext): Promise { +export async function getGatewayWallet(ctx: IRestApiContext): Promise { return await makeRestApiRequest(ctx, 'GET', '/ai/gateway/wallet'); } diff --git a/packages/frontend/editor-ui/src/features/credentials/components/NodeCredentials.test.ts b/packages/frontend/editor-ui/src/features/credentials/components/NodeCredentials.test.ts index b32a7bbc10f..d73b45579bc 100644 --- a/packages/frontend/editor-ui/src/features/credentials/components/NodeCredentials.test.ts +++ b/packages/frontend/editor-ui/src/features/credentials/components/NodeCredentials.test.ts @@ -56,6 +56,7 @@ vi.mock('@/app/composables/useAiGateway', () => ({ canServeCredentialType: vi.fn(() => false), balance: computed(() => undefined), budget: computed(() => undefined), + creditsLabelKey: computed(() => 'generic.freeCredits'), fetchConfig: vi.fn().mockResolvedValue(undefined), fetchWallet: vi.fn().mockResolvedValue(undefined), saveAfterToggle: vi.fn().mockResolvedValue(undefined), @@ -1473,6 +1474,7 @@ describe('NodeCredentials', () => { isNodePropertyHidden: vi.fn(() => false), balance: computed(() => undefined), budget: computed(() => undefined), + creditsLabelKey: computed(() => 'generic.freeCredits'), fetchConfig: vi.fn().mockResolvedValue(undefined), fetchWallet: vi.fn().mockResolvedValue(undefined), saveAfterToggle: vi.fn().mockResolvedValue(undefined), @@ -1544,6 +1546,7 @@ describe('NodeCredentials', () => { isNodePropertyHidden: vi.fn(() => false), balance: computed(() => undefined), budget: computed(() => undefined), + creditsLabelKey: computed(() => 'generic.freeCredits'), fetchError: computed(() => null), fetchConfig: vi.fn().mockResolvedValue(undefined), fetchWallet: vi.fn().mockResolvedValue(undefined), @@ -1574,6 +1577,7 @@ describe('NodeCredentials', () => { isNodePropertyHidden: vi.fn(() => false), balance: computed(() => undefined), budget: computed(() => undefined), + creditsLabelKey: computed(() => 'generic.freeCredits'), fetchError: computed(() => null), fetchConfig: vi.fn().mockResolvedValue(undefined), fetchWallet: vi.fn().mockResolvedValue(undefined), @@ -1668,6 +1672,7 @@ describe('NodeCredentials', () => { isNodePropertyHidden: vi.fn(() => false), balance: computed(() => undefined), budget: computed(() => undefined), + creditsLabelKey: computed(() => 'generic.freeCredits'), fetchConfig: vi.fn().mockResolvedValue(undefined), fetchWallet: vi.fn().mockResolvedValue(undefined), saveAfterToggle: vi.fn().mockResolvedValue(undefined), @@ -1713,6 +1718,7 @@ describe('NodeCredentials', () => { isNodePropertyHidden: vi.fn(() => false), balance: computed(() => 2.75), budget: computed(() => undefined), + creditsLabelKey: computed(() => 'generic.freeCredits'), fetchConfig: vi.fn().mockResolvedValue(undefined), fetchWallet: vi.fn().mockResolvedValue(undefined), saveAfterToggle: vi.fn().mockResolvedValue(undefined), @@ -1864,6 +1870,7 @@ describe('NodeCredentials', () => { isNodePropertyHidden: vi.fn(() => false), balance: computed(() => undefined), budget: computed(() => undefined), + creditsLabelKey: computed(() => 'generic.freeCredits'), fetchConfig: vi.fn().mockResolvedValue(undefined), fetchWallet: vi.fn().mockResolvedValue(undefined), saveAfterToggle: vi.fn().mockResolvedValue(undefined), @@ -2028,6 +2035,7 @@ describe('NodeCredentials', () => { isNodePropertyHidden: vi.fn(() => false), balance: computed(() => undefined), budget: computed(() => undefined), + creditsLabelKey: computed(() => 'generic.freeCredits'), fetchConfig: vi.fn().mockResolvedValue(undefined), fetchWallet: vi.fn().mockResolvedValue(undefined), saveAfterToggle: vi.fn().mockResolvedValue(undefined), @@ -2068,6 +2076,7 @@ describe('NodeCredentials', () => { isNodePropertyHidden: vi.fn(() => false), balance: computed(() => undefined), budget: computed(() => undefined), + creditsLabelKey: computed(() => 'generic.freeCredits'), fetchConfig: vi.fn().mockResolvedValue(undefined), fetchWallet: vi.fn().mockResolvedValue(undefined), saveAfterToggle: vi.fn().mockResolvedValue(undefined), @@ -2107,6 +2116,7 @@ describe('NodeCredentials', () => { isNodePropertyHidden: vi.fn(() => false), balance: computed(() => undefined), budget: computed(() => undefined), + creditsLabelKey: computed(() => 'generic.freeCredits'), fetchConfig: vi.fn().mockResolvedValue(undefined), fetchWallet: vi.fn().mockResolvedValue(undefined), saveAfterToggle: vi.fn().mockResolvedValue(undefined), @@ -2147,6 +2157,7 @@ describe('NodeCredentials', () => { isNodePropertyHidden: vi.fn(() => false), balance: computed(() => undefined), budget: computed(() => undefined), + creditsLabelKey: computed(() => 'generic.freeCredits'), fetchConfig: vi.fn().mockResolvedValue(undefined), fetchWallet: vi.fn().mockResolvedValue(undefined), saveAfterToggle: vi.fn().mockResolvedValue(undefined), @@ -2289,6 +2300,7 @@ describe('NodeCredentials', () => { isNodePropertyHidden: vi.fn(() => false), balance: computed(() => undefined), budget: computed(() => undefined), + creditsLabelKey: computed(() => 'generic.freeCredits'), fetchConfig: vi.fn().mockResolvedValue(undefined), fetchWallet: vi.fn().mockResolvedValue(undefined), saveAfterToggle: vi.fn().mockResolvedValue(undefined), @@ -2416,6 +2428,7 @@ describe('NodeCredentials', () => { isNodePropertyHidden: vi.fn(() => false), balance: computed(() => undefined), budget: computed(() => undefined), + creditsLabelKey: computed(() => 'generic.freeCredits'), fetchConfig: vi.fn().mockResolvedValue(undefined), fetchWallet: vi.fn().mockResolvedValue(undefined), saveAfterToggle: vi.fn().mockResolvedValue(undefined), @@ -2521,6 +2534,7 @@ describe('NodeCredentials', () => { isNodePropertyHidden: vi.fn(() => false), balance: computed(() => undefined), budget: computed(() => undefined), + creditsLabelKey: computed(() => 'generic.freeCredits'), fetchConfig: vi.fn().mockResolvedValue(undefined), fetchWallet: vi.fn().mockResolvedValue(undefined), saveAfterToggle: vi.fn().mockResolvedValue(undefined), diff --git a/packages/frontend/editor-ui/src/features/ndv/parameters/components/ParameterInputList.test.ts b/packages/frontend/editor-ui/src/features/ndv/parameters/components/ParameterInputList.test.ts index 6e9bd96d55f..40d832290ab 100644 --- a/packages/frontend/editor-ui/src/features/ndv/parameters/components/ParameterInputList.test.ts +++ b/packages/frontend/editor-ui/src/features/ndv/parameters/components/ParameterInputList.test.ts @@ -90,6 +90,7 @@ vi.mock('@/app/composables/useAiGateway', () => ({ isNodePropertyHidden: vi.fn(() => false), balance: { value: undefined }, budget: { value: undefined }, + creditsLabelKey: { value: 'generic.freeCredits' }, fetchError: { value: null }, fetchConfig: vi.fn(), fetchWallet: vi.fn(), @@ -1953,6 +1954,7 @@ describe('ParameterInputList', () => { isNodePropertyHidden: vi.fn(() => false), balance: { value: undefined } as never, budget: { value: undefined } as never, + creditsLabelKey: { value: 'generic.freeCredits' } as never, fetchError: { value: null } as never, fetchConfig: vi.fn(), fetchWallet: vi.fn(), @@ -1990,6 +1992,7 @@ describe('ParameterInputList', () => { isNodePropertyHidden: vi.fn(() => false), balance: { value: undefined } as never, budget: { value: undefined } as never, + creditsLabelKey: { value: 'generic.freeCredits' } as never, fetchError: { value: null } as never, fetchConfig: vi.fn(), fetchWallet: vi.fn(), @@ -2050,6 +2053,7 @@ describe('ParameterInputList', () => { ), balance: { value: undefined } as never, budget: { value: undefined } as never, + creditsLabelKey: { value: 'generic.freeCredits' } as never, fetchError: { value: null } as never, fetchConfig: vi.fn(), fetchWallet: vi.fn(), @@ -2100,6 +2104,7 @@ describe('ParameterInputList', () => { ), balance: { value: undefined } as never, budget: { value: undefined } as never, + creditsLabelKey: { value: 'generic.freeCredits' } as never, fetchError: { value: null } as never, fetchConfig: vi.fn(), fetchWallet: vi.fn(), @@ -2168,6 +2173,7 @@ describe('ParameterInputList', () => { isNodePropertyHidden: vi.fn(() => false), balance: { value: undefined } as never, budget: { value: undefined } as never, + creditsLabelKey: { value: 'generic.freeCredits' } as never, fetchError: { value: null } as never, fetchConfig: vi.fn(), fetchWallet: vi.fn(), @@ -2204,6 +2210,7 @@ describe('ParameterInputList', () => { isNodePropertyHidden: vi.fn(() => false), balance: { value: undefined } as never, budget: { value: undefined } as never, + creditsLabelKey: { value: 'generic.freeCredits' } as never, fetchError: { value: null } as never, fetchConfig: vi.fn(), fetchWallet: vi.fn(), @@ -2244,6 +2251,7 @@ describe('ParameterInputList', () => { isNodePropertyHidden: vi.fn(() => false), balance: { value: undefined } as never, budget: { value: undefined } as never, + creditsLabelKey: { value: 'generic.freeCredits' } as never, fetchError: { value: null } as never, fetchConfig: vi.fn(), fetchWallet: vi.fn(), @@ -2289,6 +2297,7 @@ describe('ParameterInputList', () => { isNodePropertyHidden: vi.fn(() => false), balance: { value: undefined } as never, budget: { value: undefined } as never, + creditsLabelKey: { value: 'generic.freeCredits' } as never, fetchError: { value: null } as never, fetchConfig: vi.fn(), fetchWallet: vi.fn(), diff --git a/packages/frontend/editor-ui/src/features/shared/nodeCreator/nodeCreator.store.test.ts b/packages/frontend/editor-ui/src/features/shared/nodeCreator/nodeCreator.store.test.ts index 72ab3c3088f..7444da1182f 100644 --- a/packages/frontend/editor-ui/src/features/shared/nodeCreator/nodeCreator.store.test.ts +++ b/packages/frontend/editor-ui/src/features/shared/nodeCreator/nodeCreator.store.test.ts @@ -148,21 +148,25 @@ describe('useNodeCreatorStore', () => { settingsStore.isAiGatewayEnabled = true; const aiGatewayStore = mockedStore(useAiGatewayStore); aiGatewayStore.fetchConfig = vi.fn(); + aiGatewayStore.fetchWallet = vi.fn(); nodeCreatorStore.onCreatorOpened({ source, mode, workflow_id }); expect(aiGatewayStore.fetchConfig).toHaveBeenCalled(); + expect(aiGatewayStore.fetchWallet).toHaveBeenCalled(); }); - it('does not fetch the gateway config when AI Gateway is disabled', () => { + it('does not fetch the gateway config or wallet when AI Gateway is disabled', () => { const settingsStore = mockedStore(useSettingsStore); settingsStore.isAiGatewayEnabled = false; const aiGatewayStore = mockedStore(useAiGatewayStore); aiGatewayStore.fetchConfig = vi.fn(); + aiGatewayStore.fetchWallet = vi.fn(); nodeCreatorStore.onCreatorOpened({ source, mode, workflow_id }); expect(aiGatewayStore.fetchConfig).not.toHaveBeenCalled(); + expect(aiGatewayStore.fetchWallet).not.toHaveBeenCalled(); }); }); diff --git a/packages/frontend/editor-ui/src/features/shared/nodeCreator/nodeCreator.store.ts b/packages/frontend/editor-ui/src/features/shared/nodeCreator/nodeCreator.store.ts index 538a6b81dba..9a97474f52f 100644 --- a/packages/frontend/editor-ui/src/features/shared/nodeCreator/nodeCreator.store.ts +++ b/packages/frontend/editor-ui/src/features/shared/nodeCreator/nodeCreator.store.ts @@ -358,10 +358,11 @@ export const useNodeCreatorStore = defineStore(STORES.NODE_CREATOR, () => { }) { resetNodesPanelSession(); - // Warm up the AI Gateway config so the n8n Connect search boost has the - // supported-node list by the time the user types. Cached after first load. + // Config for Connect search boost; wallet for the credits pill. if (useSettingsStore().isAiGatewayEnabled) { - void useAiGatewayStore().fetchConfig(); + const aiGatewayStore = useAiGatewayStore(); + void aiGatewayStore.fetchConfig(); + void aiGatewayStore.fetchWallet(); } trackNodeCreatorEvent('User opened nodes panel', { diff --git a/packages/frontend/editor-ui/src/features/shared/nodeCreator/nodeCreator.utils.test.ts b/packages/frontend/editor-ui/src/features/shared/nodeCreator/nodeCreator.utils.test.ts index fbb5bfad3f8..9f1317dc4e7 100644 --- a/packages/frontend/editor-ui/src/features/shared/nodeCreator/nodeCreator.utils.test.ts +++ b/packages/frontend/editor-ui/src/features/shared/nodeCreator/nodeCreator.utils.test.ts @@ -68,6 +68,7 @@ vi.mock('@/app/stores/aiGateway.store', async (importOriginal) => ({ useAiGatewayStore: vi.fn(() => ({ isNodeSupported: vi.fn(() => false), isNodeTypeVersionSupported: vi.fn(() => true), + creditsLabelKey: 'generic.freeCredits', })), })); @@ -790,6 +791,7 @@ describe('NodeCreator - utils', () => { vi.mocked(useAiGatewayStore).mockReturnValue({ isNodeSupported: vi.fn(() => true), isNodeTypeVersionSupported: vi.fn(() => true), + creditsLabelKey: 'generic.freeCredits', } as unknown as ReturnType); vi.mocked(useNodeTypesStore).mockReturnValue({ getNodeVersions: vi.fn(() => [1, 1.1]), @@ -801,6 +803,32 @@ describe('NodeCreator - utils', () => { expect(result.properties.tag).toEqual({ text: 'Free credits', pill: true }); }); + it('should show n8n credits badge after a top-up or depleted allowance', () => { + vi.mocked(useAiGatewayStore).mockReturnValue({ + isNodeSupported: vi.fn(() => true), + isNodeTypeVersionSupported: vi.fn(() => true), + creditsLabelKey: 'generic.n8nCredits', + } as unknown as ReturnType); + + const [result] = finalizeItems([makeGatewayNode()]) as NodeCreateElement[]; + expect(result.properties.tag).toEqual({ text: 'n8n credits', pill: true, type: 'info' }); + }); + + it('should retag credits when the wallet arrives after the first finalize', () => { + const items = [makeGatewayNode()]; + const [first] = finalizeItems(items) as NodeCreateElement[]; + expect(first.properties.tag).toEqual({ text: 'Free credits', pill: true }); + + vi.mocked(useAiGatewayStore).mockReturnValue({ + isNodeSupported: vi.fn(() => true), + isNodeTypeVersionSupported: vi.fn(() => true), + creditsLabelKey: 'generic.n8nCredits', + } as unknown as ReturnType); + + const [second] = finalizeItems(items) as NodeCreateElement[]; + expect(second.properties.tag).toEqual({ text: 'n8n credits', pill: true, type: 'info' }); + }); + it('should suppress Free credits badge when latest version is below the minimum', () => { vi.mocked(useAiGatewayStore).mockReturnValue({ isNodeSupported: vi.fn(() => true), @@ -828,6 +856,7 @@ describe('NodeCreator - utils', () => { vi.mocked(useAiGatewayStore).mockReturnValue({ isNodeSupported: vi.fn(() => true), isNodeTypeVersionSupported, + creditsLabelKey: 'generic.freeCredits', } as unknown as ReturnType); finalizeItems([makeGatewayNode('my-node')]); @@ -843,6 +872,7 @@ describe('NodeCreator - utils', () => { vi.mocked(useAiGatewayStore).mockReturnValue({ isNodeSupported: vi.fn(() => true), isNodeTypeVersionSupported, + creditsLabelKey: 'generic.freeCredits', } as unknown as ReturnType); finalizeItems([makeGatewayNode('my-node')]); @@ -860,6 +890,7 @@ describe('NodeCreator - utils', () => { vi.mocked(useAiGatewayStore).mockReturnValue({ isNodeSupported: vi.fn(() => true), isNodeTypeVersionSupported, + creditsLabelKey: 'generic.freeCredits', } as unknown as ReturnType); finalizeItems([makeGatewayNode('my-node')]); @@ -871,6 +902,7 @@ describe('NodeCreator - utils', () => { vi.mocked(useAiGatewayStore).mockReturnValue({ isNodeSupported: vi.fn((name: string) => name === 'llamaParsePlatform'), isNodeTypeVersionSupported: vi.fn(() => true), + creditsLabelKey: 'generic.freeCredits', } as unknown as ReturnType); const [result] = finalizeItems([ @@ -885,6 +917,7 @@ describe('NodeCreator - utils', () => { vi.mocked(useAiGatewayStore).mockReturnValue({ isNodeSupported: vi.fn((name: string) => name === '@vendor/n8n-nodes-connect.connect'), isNodeTypeVersionSupported: vi.fn(() => true), + creditsLabelKey: 'generic.freeCredits', } as unknown as ReturnType); const [result] = finalizeItems([ @@ -1011,6 +1044,7 @@ describe('NodeCreator - utils', () => { vi.mocked(useAiGatewayStore).mockReturnValue({ isNodeSupported: vi.fn((name: string) => name.startsWith('supported')), isNodeTypeVersionSupported: vi.fn(() => true), + creditsLabelKey: 'generic.freeCredits', } as unknown as ReturnType); vi.mocked(useNodeTypesStore).mockReturnValue({ getNodeVersions: vi.fn(() => [1]), diff --git a/packages/frontend/editor-ui/src/features/shared/nodeCreator/nodeCreator.utils.ts b/packages/frontend/editor-ui/src/features/shared/nodeCreator/nodeCreator.utils.ts index 1c6a548aad0..593cb2867e7 100644 --- a/packages/frontend/editor-ui/src/features/shared/nodeCreator/nodeCreator.utils.ts +++ b/packages/frontend/editor-ui/src/features/shared/nodeCreator/nodeCreator.utils.ts @@ -474,10 +474,11 @@ function applyNodeTags(element: INodeCreateElement): INodeCreateElement { text: i18n.baseText('generic.betaProper'), }; } else if (isAiGatewayEligibleNode(element.properties.name)) { - element.properties.tag = { - text: i18n.baseText('generic.freeCredits'), - pill: true, - }; + const creditsLabelKey = useAiGatewayStore().creditsLabelKey; + element.properties.tag = + creditsLabelKey === 'generic.freeCredits' + ? { text: i18n.baseText(creditsLabelKey), pill: true } + : { text: i18n.baseText(creditsLabelKey), pill: true, type: 'info' }; } return element; @@ -485,10 +486,16 @@ function applyNodeTags(element: INodeCreateElement): INodeCreateElement { export function finalizeItems(items: INodeCreateElement[]): INodeCreateElement[] { return items - .map((item) => ({ - ...item, - uuid: `${item.key}-${uuidv4()}`, - })) + .map((item) => + item.type !== 'node' + ? { ...item, uuid: `${item.key}-${uuidv4()}` } + : { + ...item, + uuid: `${item.key}-${uuidv4()}`, + // Clone so applyNodeTags cannot stamp a stale credits tag onto baselineItems. + properties: { ...item.properties }, + }, + ) .map(applyNodeTags); }