mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-17 13:39:44 +02:00
feat(editor): Switch credits pill after a top-up or depleted allowance (#36239)
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
a5e3213df4
commit
516c19e2ed
|
|
@ -0,0 +1,5 @@
|
|||
export interface AiGatewayWalletResponse {
|
||||
budget: number;
|
||||
balance: number;
|
||||
hasEverToppedUp: boolean;
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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<AiGatewayWalletResponse> {
|
||||
this.aiGatewayService.assertEnabled();
|
||||
try {
|
||||
return await this.aiGatewayService.getWallet(req.user.id);
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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<GatewayWalletResponse> {
|
||||
async getWallet(userId: string): Promise<AiGatewayWalletResponse> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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<typeof N8nActionPill>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {};
|
||||
|
||||
export const Info: Story = {
|
||||
args: {
|
||||
text: 'n8n credits',
|
||||
type: 'info',
|
||||
},
|
||||
};
|
||||
|
||||
export const Danger: Story = {
|
||||
args: {
|
||||
text: 'No credits',
|
||||
type: 'danger',
|
||||
},
|
||||
};
|
||||
|
|
@ -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' },
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -61,6 +61,18 @@ exports[`N8nActionPill > snapshots > renders default type correctly 1`] = `
|
|||
</div>
|
||||
`;
|
||||
|
||||
exports[`N8nActionPill > snapshots > renders info type correctly 1`] = `
|
||||
<div>
|
||||
<span
|
||||
class="root info"
|
||||
>
|
||||
|
||||
n8n credits
|
||||
|
||||
</span>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`N8nActionPill > snapshots > renders small size correctly 1`] = `
|
||||
<div>
|
||||
<span
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import type { DropdownMenuItemProps } from '../N8nDropdownMenu/DropdownMenu.type
|
|||
export interface AiModelSelectorMenuItemData {
|
||||
badgeLabel?: string;
|
||||
/** Right-aligned action pill on the item (e.g. an n8n credits balance). */
|
||||
actionPill?: { text: string; type?: 'default' | 'danger' };
|
||||
actionPill?: { text: string; type?: 'default' | 'danger' | 'info' };
|
||||
description?: string;
|
||||
descriptionTooltipTeleported?: boolean;
|
||||
fullName?: string;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,29 @@
|
|||
import { render } from '@testing-library/vue';
|
||||
|
||||
import NodeCreatorNode from './NodeCreatorNode.vue';
|
||||
|
||||
function renderNode(tag: { text: string; pill?: boolean; type?: string }) {
|
||||
return render(NodeCreatorNode, {
|
||||
props: { title: 'OpenAI', tag },
|
||||
global: { stubs: ['N8nIcon'] },
|
||||
});
|
||||
}
|
||||
|
||||
describe('N8nNodeCreatorNode', () => {
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
|
@ -47,7 +47,12 @@ const { t } = useI18n();
|
|||
<div :class="$style.details">
|
||||
<span :class="$style.name" data-test-id="node-creator-item-name" v-text="title" />
|
||||
<PreviewTag v-if="tag?.preview" size="small" :class="$style.previewTag" :text="tag.text" />
|
||||
<N8nActionPill v-else-if="tag?.pill" size="small" :text="tag.text" />
|
||||
<N8nActionPill
|
||||
v-else-if="tag?.pill"
|
||||
size="small"
|
||||
:text="tag.text"
|
||||
:type="tag.type === 'info' || tag.type === 'danger' ? tag.type : 'default'"
|
||||
/>
|
||||
<ElTag
|
||||
v-else-if="tag"
|
||||
:class="$style.tag"
|
||||
|
|
|
|||
|
|
@ -95,6 +95,7 @@
|
|||
"generic.beta": "beta",
|
||||
"generic.betaProper": "Beta",
|
||||
"generic.freeCredits": "Free credits",
|
||||
"generic.n8nCredits": "n8n credits",
|
||||
"generic.yes": "Yes",
|
||||
"generic.no": "No",
|
||||
"generic.rating": "Rating",
|
||||
|
|
|
|||
|
|
@ -61,7 +61,11 @@ describe('useAiGateway', () => {
|
|||
|
||||
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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()', () => {
|
||||
|
|
|
|||
|
|
@ -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<AiGatewayConfigDto | null>(null);
|
||||
const balance = ref<number | undefined>(undefined);
|
||||
const budget = ref<number | undefined>(undefined);
|
||||
const hasEverToppedUp = ref<boolean | undefined>(undefined);
|
||||
const creditsLabelKey = computed((): 'generic.freeCredits' | 'generic.n8nCredits' =>
|
||||
usesFreeCreditsLabel(hasEverToppedUp.value, balance.value)
|
||||
? 'generic.freeCredits'
|
||||
: 'generic.n8nCredits',
|
||||
);
|
||||
const usageEntries = ref<AiGatewayUsageEntry[]>([]);
|
||||
const usageTotal = ref<number>(0);
|
||||
const fetchError = ref<Error | null>(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<void> | null = null;
|
||||
let walletFetch: Promise<void> | null = null;
|
||||
|
||||
async function fetchConfig(): Promise<void> {
|
||||
if (config.value !== null) return;
|
||||
|
|
@ -52,14 +67,20 @@ export const useAiGatewayStore = defineStore(STORES.AI_GATEWAY, () => {
|
|||
}
|
||||
|
||||
async function fetchWallet(): Promise<void> {
|
||||
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<void> {
|
||||
|
|
@ -189,6 +210,8 @@ export const useAiGatewayStore = defineStore(STORES.AI_GATEWAY, () => {
|
|||
config,
|
||||
balance,
|
||||
budget,
|
||||
hasEverToppedUp,
|
||||
creditsLabelKey,
|
||||
usageEntries,
|
||||
usageTotal,
|
||||
fetchError,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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<string, string | number> }) => {
|
||||
|
|
@ -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<string>();
|
||||
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<string>();
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
|
|
|
|||
|
|
@ -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<AiGatewayC
|
|||
return await makeRestApiRequest(ctx, 'GET', '/ai/gateway/config');
|
||||
}
|
||||
|
||||
export async function getGatewayWallet(ctx: IRestApiContext): Promise<{
|
||||
budget: number;
|
||||
balance: number;
|
||||
}> {
|
||||
export async function getGatewayWallet(ctx: IRestApiContext): Promise<AiGatewayWalletResponse> {
|
||||
return await makeRestApiRequest(ctx, 'GET', '/ai/gateway/wallet');
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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', {
|
||||
|
|
|
|||
|
|
@ -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<typeof useAiGatewayStore>);
|
||||
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<typeof useAiGatewayStore>);
|
||||
|
||||
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<typeof useAiGatewayStore>);
|
||||
|
||||
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<typeof useAiGatewayStore>);
|
||||
|
||||
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<typeof useAiGatewayStore>);
|
||||
|
||||
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<typeof useAiGatewayStore>);
|
||||
|
||||
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<typeof useAiGatewayStore>);
|
||||
|
||||
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<typeof useAiGatewayStore>);
|
||||
|
||||
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<typeof useAiGatewayStore>);
|
||||
vi.mocked(useNodeTypesStore).mockReturnValue({
|
||||
getNodeVersions: vi.fn(() => [1]),
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user