mirror of
https://github.com/n8n-io/n8n
synced 2026-04-21 15:47:20 +00:00
feat: AI Gateway Top Up Flow (#28113)
Co-authored-by: Dawid Myslak <dawid.myslak@gmail.com>
This commit is contained in:
parent
9ab974b7b0
commit
2c4b9749c7
33 changed files with 1135 additions and 354 deletions
|
|
@ -8,7 +8,7 @@ import { N8N_VERSION, AI_ASSISTANT_SDK_VERSION } from '@/constants';
|
|||
import { FeatureNotLicensedError } from '@/errors/feature-not-licensed.error';
|
||||
import type { License } from '@/license';
|
||||
import { AiGatewayService } from '@/services/ai-gateway.service';
|
||||
import type { Project, User } from '@n8n/db';
|
||||
import type { Project, User, UserRepository } from '@n8n/db';
|
||||
import type { OwnershipService } from '@/services/ownership.service';
|
||||
|
||||
const BASE_URL = 'http://gateway.test';
|
||||
|
|
@ -33,6 +33,7 @@ function makeService({
|
|||
baseUrl = BASE_URL as string | null,
|
||||
isAiGatewayLicensed = true,
|
||||
ownershipService = mock<OwnershipService>(),
|
||||
userRepository = mock<UserRepository>({ findOneBy: jest.fn().mockResolvedValue(null) }),
|
||||
} = {}) {
|
||||
const globalConfig = {
|
||||
aiAssistant: { baseUrl: baseUrl ?? undefined },
|
||||
|
|
@ -51,6 +52,7 @@ function makeService({
|
|||
licenseState,
|
||||
instanceSettings,
|
||||
ownershipService,
|
||||
userRepository,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -192,6 +194,60 @@ describe('AiGatewayService', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('includes userEmail and userName in token body when user exists', async () => {
|
||||
const userRepository = mock<UserRepository>({
|
||||
findOneBy: jest
|
||||
.fn()
|
||||
.mockResolvedValue(
|
||||
mock<User>({ email: 'alice@example.com', firstName: 'Alice', lastName: 'Smith' }),
|
||||
),
|
||||
});
|
||||
mockConfigThenToken(fetchMock);
|
||||
const service = makeService({ userRepository });
|
||||
|
||||
await service.getSyntheticCredential({ credentialType: 'googlePalmApi', userId: USER_ID });
|
||||
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
`${BASE_URL}/v1/gateway/credentials`,
|
||||
expect.objectContaining({
|
||||
body: JSON.stringify({
|
||||
licenseCert: LICENSE_CERT,
|
||||
userEmail: 'alice@example.com',
|
||||
userName: 'Alice Smith',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('omits userName from token body when user has no first or last name', async () => {
|
||||
const userRepository = mock<UserRepository>({
|
||||
findOneBy: jest
|
||||
.fn()
|
||||
.mockResolvedValue(
|
||||
mock<User>({ email: 'alice@example.com', firstName: '', lastName: '' }),
|
||||
),
|
||||
});
|
||||
mockConfigThenToken(fetchMock);
|
||||
const service = makeService({ userRepository });
|
||||
|
||||
await service.getSyntheticCredential({ credentialType: 'googlePalmApi', userId: USER_ID });
|
||||
|
||||
const body = JSON.parse(fetchMock.mock.calls[1][1].body as string);
|
||||
expect(body.userEmail).toBe('alice@example.com');
|
||||
expect(body.userName).toBeUndefined();
|
||||
});
|
||||
|
||||
it('omits userEmail and userName from token body when user is not found', async () => {
|
||||
mockConfigThenToken(fetchMock);
|
||||
const service = makeService(); // userRepository returns null by default
|
||||
|
||||
await service.getSyntheticCredential({ credentialType: 'googlePalmApi', userId: USER_ID });
|
||||
|
||||
const body = JSON.parse(fetchMock.mock.calls[1][1].body as string);
|
||||
expect(body).toEqual({ licenseCert: LICENSE_CERT });
|
||||
});
|
||||
|
||||
it('caches config and token — second call makes no additional fetches', async () => {
|
||||
mockConfigThenToken(fetchMock);
|
||||
const service = makeService();
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { LicenseState } from '@n8n/backend-common';
|
|||
import { GlobalConfig } from '@n8n/config';
|
||||
import { LICENSE_FEATURES } from '@n8n/constants';
|
||||
import { Service } from '@n8n/di';
|
||||
import { UserRepository } from '@n8n/db';
|
||||
import { InstanceSettings } from 'n8n-core';
|
||||
import type { ICredentialDataDecryptedObject } from 'n8n-workflow';
|
||||
import { UserError } from 'n8n-workflow';
|
||||
|
|
@ -42,6 +43,7 @@ export class AiGatewayService {
|
|||
private readonly licenseState: LicenseState,
|
||||
private readonly instanceSettings: InstanceSettings,
|
||||
private readonly ownershipService: OwnershipService,
|
||||
private readonly userRepository: UserRepository,
|
||||
) {}
|
||||
|
||||
/**
|
||||
|
|
@ -168,12 +170,15 @@ export class AiGatewayService {
|
|||
throw new UserError(`Failed to fetch AI Gateway credits: HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const data = (await response.json()) as GatewayCreditsResponse;
|
||||
if (typeof data.creditsQuota !== 'number' || typeof data.creditsRemaining !== 'number') {
|
||||
return this.parseCreditsResponse(await response.json());
|
||||
}
|
||||
|
||||
private parseCreditsResponse(data: unknown): GatewayCreditsResponse {
|
||||
const d = data as GatewayCreditsResponse;
|
||||
if (typeof d.creditsQuota !== 'number' || typeof d.creditsRemaining !== 'number') {
|
||||
throw new UserError('AI Gateway returned an invalid credits response.');
|
||||
}
|
||||
|
||||
return data;
|
||||
return d;
|
||||
}
|
||||
|
||||
private requireBaseUrl(): string {
|
||||
|
|
@ -254,12 +259,21 @@ export class AiGatewayService {
|
|||
|
||||
private async fetchAndCacheToken(userId: string, key: string): Promise<string> {
|
||||
const baseUrl = this.requireBaseUrl();
|
||||
const licenseCert = await this.license.loadCertStr();
|
||||
const [licenseCert, user] = await Promise.all([
|
||||
this.license.loadCertStr(),
|
||||
this.userRepository.findOneBy({ id: userId }),
|
||||
]);
|
||||
|
||||
const response = await fetch(`${baseUrl}/v1/gateway/credentials`, {
|
||||
method: 'POST',
|
||||
headers: this.buildGatewayCredentialsHeaders(userId),
|
||||
body: JSON.stringify({ licenseCert }),
|
||||
body: JSON.stringify({
|
||||
licenseCert,
|
||||
...(user?.email && { userEmail: user.email }),
|
||||
...(user && {
|
||||
userName: [user.firstName, user.lastName].filter(Boolean).join(' ') || undefined,
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,112 @@
|
|||
<script lang="ts" setup>
|
||||
/**
|
||||
* A small pill-shaped label that can optionally act as a button.
|
||||
* Use for inline status indicators, counts, or any short contextual label
|
||||
*/
|
||||
import { ref } from 'vue';
|
||||
|
||||
defineOptions({ name: 'N8nActionPill' });
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
/** Text to display when the default slot is empty. */
|
||||
text?: string;
|
||||
/** When set, swaps to this text on hover (e.g. show a count normally, an action label on hover). */
|
||||
hoverText?: string;
|
||||
/** Enables pointer cursor and hover styles — use when the pill triggers an action. */
|
||||
clickable?: boolean;
|
||||
/** 'small' matches BetaTag/PreviewTag scale (for menus/lists); 'medium' is the default. */
|
||||
size?: 'small' | 'medium';
|
||||
}>(),
|
||||
{
|
||||
text: undefined,
|
||||
hoverText: undefined,
|
||||
clickable: false,
|
||||
size: 'medium',
|
||||
},
|
||||
);
|
||||
|
||||
defineEmits<{
|
||||
click: [event: MouseEvent];
|
||||
}>();
|
||||
|
||||
const hovered = ref(false);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span
|
||||
:class="[
|
||||
$style.root,
|
||||
size === 'small' && $style.small,
|
||||
clickable && $style.clickable,
|
||||
hoverText && hovered && $style.pressed,
|
||||
]"
|
||||
@mouseenter="hoverText && (hovered = true)"
|
||||
@mouseleave="hoverText && (hovered = false)"
|
||||
@click="$emit('click', $event)"
|
||||
>
|
||||
<span v-if="hoverText" :class="$style.labelGrid">
|
||||
<span :class="[$style.label, hovered && $style.labelHidden]"
|
||||
><slot>{{ text }}</slot></span
|
||||
>
|
||||
<span :class="[$style.label, !hovered && $style.labelHidden]">{{ hoverText }}</span>
|
||||
</span>
|
||||
<slot v-else>{{ text }}</slot>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
.root {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
padding: var(--spacing--4xs) var(--spacing--2xs);
|
||||
border: none;
|
||||
border-radius: 16px;
|
||||
background-color: var(--color--green-100);
|
||||
font-size: var(--font-size--2xs);
|
||||
font-weight: var(--font-weight--medium);
|
||||
color: var(--color--green-900);
|
||||
white-space: nowrap;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.small {
|
||||
padding: var(--spacing--5xs) var(--spacing--4xs);
|
||||
font-size: var(--font-size--3xs);
|
||||
}
|
||||
|
||||
.clickable {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
transition:
|
||||
background-color 0.15s ease,
|
||||
color 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--color--green-200);
|
||||
color: var(--color--green-950);
|
||||
}
|
||||
}
|
||||
|
||||
.pressed {
|
||||
background-color: var(--color--green-200);
|
||||
color: var(--color--green-950);
|
||||
}
|
||||
|
||||
.labelGrid {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.label {
|
||||
grid-area: 1 / 1;
|
||||
text-align: center;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.labelHidden {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -182,6 +182,7 @@ import IconLucidePencilOff from '~icons/lucide/pencil-off';
|
|||
import IconLucidePin from '~icons/lucide/pin';
|
||||
import IconLucidePlay from '~icons/lucide/play';
|
||||
import IconLucidePlug from '~icons/lucide/plug';
|
||||
import IconLucidePlugZap from '~icons/lucide/plug-zap';
|
||||
import IconLucidePlus from '~icons/lucide/plus';
|
||||
import IconLucidePocketKnife from '~icons/lucide/pocket-knife';
|
||||
import IconLucidePower from '~icons/lucide/power';
|
||||
|
|
@ -404,6 +405,7 @@ export const deprecatedIconSet = {
|
|||
play: IconLucidePlay,
|
||||
'play-circle': IconLucideCirclePlay,
|
||||
plug: IconLucidePlug,
|
||||
'plug-zap': IconLucidePlugZap,
|
||||
plus: IconLucidePlus,
|
||||
'plus-circle': IconLucideCirclePlus,
|
||||
'plus-square': IconLucideSquarePlus,
|
||||
|
|
@ -645,6 +647,7 @@ export const updatedIconSet = {
|
|||
pin: IconLucidePin,
|
||||
play: IconLucidePlay,
|
||||
plug: IconLucidePlug,
|
||||
'plug-zap': IconLucidePlugZap,
|
||||
plus: IconLucidePlus,
|
||||
'pocket-knife': IconLucidePocketKnife,
|
||||
power: IconLucidePower,
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { computed } from 'vue';
|
|||
import type { IMenuItem } from '@n8n/design-system/types';
|
||||
|
||||
import BetaTag from '../BetaTag/BetaTag.vue';
|
||||
import N8nActionPill from '../N8nActionPill/ActionPill.vue';
|
||||
import N8nIcon from '../N8nIcon';
|
||||
import type { IconName } from '../N8nIcon/icons';
|
||||
import N8nRoute from '../N8nRoute';
|
||||
|
|
@ -143,6 +144,7 @@ const tooltipPlacement = computed(() => {
|
|||
text="New"
|
||||
:class="$style.newTag"
|
||||
/>
|
||||
<N8nActionPill v-if="!compact && item.creditsTag" size="small" :text="item.creditsTag" />
|
||||
</div>
|
||||
<N8nIcon v-if="item.children && !compact" icon="chevron-right" color="text-light" />
|
||||
</N8nRoute>
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { ElTag } from 'element-plus';
|
|||
|
||||
import { useI18n } from '../../composables/useI18n';
|
||||
import type { NodeCreatorTag } from '../../types/node-creator-node';
|
||||
import N8nActionPill from '../N8nActionPill/ActionPill.vue';
|
||||
import N8nBadge from '../N8nBadge';
|
||||
import N8nIcon from '../N8nIcon';
|
||||
|
||||
|
|
@ -44,8 +45,9 @@ const { t } = useI18n();
|
|||
<div>
|
||||
<div :class="$style.details">
|
||||
<span :class="$style.name" data-test-id="node-creator-item-name" v-text="title" />
|
||||
<N8nActionPill v-if="tag?.pill" size="small" :text="tag.text" />
|
||||
<ElTag
|
||||
v-if="tag"
|
||||
v-else-if="tag"
|
||||
:class="$style.tag"
|
||||
disable-transitions
|
||||
size="small"
|
||||
|
|
|
|||
|
|
@ -178,10 +178,10 @@ watch(
|
|||
|
||||
<style lang="scss" module>
|
||||
.popoverContent {
|
||||
--popover-slide-x: 0px;
|
||||
--popover-slide-y: 0px;
|
||||
--popover-origin-x: center;
|
||||
--popover-origin-y: center;
|
||||
--popover--offset--slide-x: 0;
|
||||
--popover--offset--slide-y: 0;
|
||||
--popover--offset--origin-x: center;
|
||||
--popover--offset--origin-y: center;
|
||||
|
||||
border-radius: var(--radius);
|
||||
background-color: var(--color--foreground--tint-2);
|
||||
|
|
@ -191,7 +191,7 @@ watch(
|
|||
rgba(0, 0, 0, 0.1) 0 10px 15px -3px,
|
||||
rgba(0, 0, 0, 0.05) 0 4px 6px -2px;
|
||||
will-change: transform, opacity;
|
||||
transform-origin: var(--popover-origin-x) var(--popover-origin-y);
|
||||
transform-origin: var(--popover--offset--origin-x) var(--popover--offset--origin-y);
|
||||
|
||||
&.enableSlideIn {
|
||||
animation-duration: var(--duration--snappy);
|
||||
|
|
@ -208,53 +208,54 @@ watch(
|
|||
}
|
||||
|
||||
.popoverContent[data-state='open'][data-side='top'] {
|
||||
--popover-slide-y: -2px;
|
||||
--popover-origin-y: bottom;
|
||||
--popover--offset--slide-y: -2px;
|
||||
--popover--offset--origin-y: bottom;
|
||||
}
|
||||
|
||||
.popoverContent[data-state='open'][data-side='right'] {
|
||||
--popover-slide-x: 2px;
|
||||
--popover-origin-x: left;
|
||||
--popover--offset--slide-x: 2px;
|
||||
--popover--offset--origin-x: left;
|
||||
}
|
||||
|
||||
.popoverContent[data-state='open'][data-side='bottom'] {
|
||||
--popover-slide-y: 2px;
|
||||
--popover-origin-y: top;
|
||||
--popover--offset--slide-y: 2px;
|
||||
--popover--offset--origin-y: top;
|
||||
}
|
||||
|
||||
.popoverContent[data-state='open'][data-side='left'] {
|
||||
--popover-slide-x: -2px;
|
||||
--popover-origin-x: right;
|
||||
--popover--offset--slide-x: -2px;
|
||||
--popover--offset--origin-x: right;
|
||||
}
|
||||
|
||||
.popoverContent[data-state='open'][data-side='top'][data-align='start'],
|
||||
.popoverContent[data-state='open'][data-side='bottom'][data-align='start'] {
|
||||
--popover-slide-x: -2px;
|
||||
--popover-origin-x: left;
|
||||
--popover--offset--slide-x: -2px;
|
||||
--popover--offset--origin-x: left;
|
||||
}
|
||||
|
||||
.popoverContent[data-state='open'][data-side='top'][data-align='end'],
|
||||
.popoverContent[data-state='open'][data-side='bottom'][data-align='end'] {
|
||||
--popover-slide-x: 2px;
|
||||
--popover-origin-x: right;
|
||||
--popover--offset--slide-x: 2px;
|
||||
--popover--offset--origin-x: right;
|
||||
}
|
||||
|
||||
.popoverContent[data-state='open'][data-side='left'][data-align='start'],
|
||||
.popoverContent[data-state='open'][data-side='right'][data-align='start'] {
|
||||
--popover-slide-y: -2px;
|
||||
--popover-origin-y: top;
|
||||
--popover--offset--slide-y: -2px;
|
||||
--popover--offset--origin-y: top;
|
||||
}
|
||||
|
||||
.popoverContent[data-state='open'][data-side='left'][data-align='end'],
|
||||
.popoverContent[data-state='open'][data-side='right'][data-align='end'] {
|
||||
--popover-slide-y: 2px;
|
||||
--popover-origin-y: bottom;
|
||||
--popover--offset--slide-y: 2px;
|
||||
--popover--offset--origin-y: bottom;
|
||||
}
|
||||
|
||||
@keyframes popoverIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translate(var(--popover-slide-x), var(--popover-slide-y)) scale(0.96);
|
||||
transform: translate(var(--popover--offset--slide-x), var(--popover--offset--slide-y))
|
||||
scale(0.96);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
|
|
|
|||
|
|
@ -60,6 +60,7 @@ export { default as N8nOption } from './N8nOption';
|
|||
export { default as N8nSectionHeader } from './N8nSectionHeader';
|
||||
export { default as N8nSelectableList } from './N8nSelectableList';
|
||||
export { default as N8nPreviewTag } from './PreviewTag/PreviewTag.vue';
|
||||
export { default as N8nActionPill } from './N8nActionPill/ActionPill.vue';
|
||||
export { default as N8nPopover } from './N8nPopover';
|
||||
export { default as N8nPopoverReka } from './N8nPopover'; // Alias for backwards compatibility
|
||||
export { default as N8nPromptInput } from './N8nPromptInput';
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ export type IMenuItem = {
|
|||
beta?: boolean;
|
||||
preview?: boolean;
|
||||
new?: boolean;
|
||||
creditsTag?: string;
|
||||
};
|
||||
|
||||
export interface ICustomMenuItem {
|
||||
|
|
|
|||
|
|
@ -3,4 +3,6 @@ import type { ElTag } from 'element-plus';
|
|||
export type NodeCreatorTag = {
|
||||
text: string;
|
||||
type?: (typeof ElTag)['type'];
|
||||
/** When true, renders as N8nActionPill instead of ElTag. */
|
||||
pill?: boolean;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -2004,9 +2004,15 @@
|
|||
"nodeCredentials.emptyState.noCredentials": "No credentials yet",
|
||||
"nodeCredentials.emptyState.setupCredential": "Set up credential",
|
||||
"nodeCredentials.oauth.accountConnected": "Account connected successfully",
|
||||
"aiGateway.toggle.label": "Connect via n8n Gateway",
|
||||
"aiGateway.toggle.description": "n8n Gateway is the easy way to manage AI model usage",
|
||||
"aiGateway.toggle.tokensRemaining": "{count} credits remaining",
|
||||
"aiGateway.toggle.label": "Connect via n8n Connect",
|
||||
"aiGateway.toggle.description": "n8n Connect is the easy way to manage AI model usage",
|
||||
"aiGateway.toggle.topUp": "Top up",
|
||||
"aiGateway.credentialMode.sectionLabel": "Credential",
|
||||
"aiGateway.credentialMode.creditsShort": "{count} credits",
|
||||
"aiGateway.credentialMode.n8nConnect.title": "n8n Connect",
|
||||
"aiGateway.credentialMode.n8nConnect.subtitle": "No API key required",
|
||||
"aiGateway.credentialMode.own.title": "My own credential",
|
||||
"aiGateway.credentialMode.own.subtitle": "Use your own API key",
|
||||
"nodeCredentials.oauth.accountConnectionFailed": "Account connection failed",
|
||||
"nodeErrorView.cause": "Cause",
|
||||
"nodeErrorView.copyToClipboard": "Copy to Clipboard",
|
||||
|
|
@ -4198,19 +4204,24 @@
|
|||
"settings.ai.confirm.message.builderDisabled": "Disabling data sending will reduce the effectiveness of AI features. Are you sure you want to proceed?",
|
||||
"settings.ai.confirm.message.builderEnabled": "Disabling data sending will turn off the AI Workflow Builder and reduce the effectiveness of AI features. Are you sure you want to proceed?",
|
||||
"settings.ai.confirm.confirmButtonText": "Yes, disable",
|
||||
"settings.n8nGateway": "n8n Gateway",
|
||||
"settings.n8nGateway.title": "n8n Gateway",
|
||||
"settings.n8nGateway.description": "View your AI model usage through the n8n Gateway.",
|
||||
"settings.n8nGateway.usage.title": "Usage",
|
||||
"settings.n8nGateway.usage.refresh": "Refresh",
|
||||
"settings.n8nGateway.usage.col.date": "Date",
|
||||
"settings.n8nGateway.usage.col.provider": "Provider",
|
||||
"settings.n8nGateway.usage.col.model": "Model",
|
||||
"settings.n8nGateway.usage.col.inputTokens": "Input Tokens",
|
||||
"settings.n8nGateway.usage.col.outputTokens": "Output Tokens",
|
||||
"settings.n8nGateway.usage.col.credits": "Credits",
|
||||
"settings.n8nGateway.usage.empty": "No usage records found.",
|
||||
"settings.n8nGateway.usage.loadMore": "Load more",
|
||||
"settings.n8nConnect": "n8n Connect",
|
||||
"settings.n8nConnect.title": "n8n Connect",
|
||||
"settings.n8nConnect.description": "The easy way to manage AI usage and cost",
|
||||
"settings.n8nConnect.usage.title": "Usage",
|
||||
"settings.n8nConnect.usage.refresh": "Refresh",
|
||||
"settings.n8nConnect.usage.col.date": "Date",
|
||||
"settings.n8nConnect.usage.col.provider": "Provider",
|
||||
"settings.n8nConnect.usage.col.model": "Model",
|
||||
"settings.n8nConnect.usage.col.inputTokens": "Input Tokens",
|
||||
"settings.n8nConnect.usage.col.outputTokens": "Output Tokens",
|
||||
"settings.n8nConnect.usage.col.credits": "Credits",
|
||||
"settings.n8nConnect.usage.empty": "No usage records found.",
|
||||
"settings.n8nConnect.usage.loadMore": "Load more",
|
||||
"settings.n8nConnect.credits.title": "Credits",
|
||||
"settings.n8nConnect.credits.remaining": "{remaining} / {quota} credits remaining",
|
||||
"settings.n8nConnect.credits.quota": "credits total",
|
||||
"settings.n8nConnect.credits.topUp": "Top up credits",
|
||||
"settings.n8nConnect.usage.refresh.tooltip": "Refresh usage records",
|
||||
"settings.instanceAi": "Instance AI",
|
||||
"settings.instanceAi.description": "Configure the Instance AI agent, model, memory, and permissions.",
|
||||
"settings.n8nAgent": "n8n Agent",
|
||||
|
|
|
|||
|
|
@ -4,10 +4,12 @@ import { screen } from '@testing-library/vue';
|
|||
import userEvent from '@testing-library/user-event';
|
||||
import { createTestingPinia } from '@pinia/testing';
|
||||
import { setActivePinia } from 'pinia';
|
||||
import AiGatewayToggle from './AiGatewayToggle.vue';
|
||||
import AiGatewaySelector from './AiGatewaySelector.vue';
|
||||
import { createComponentRenderer } from '@/__tests__/render';
|
||||
import { mockedStore } from '@/__tests__/utils';
|
||||
import { useWorkflowsStore } from '@/app/stores/workflows.store';
|
||||
import { useUIStore } from '@/app/stores/ui.store';
|
||||
import { AI_GATEWAY_TOP_UP_MODAL_KEY } from '@/app/constants';
|
||||
|
||||
const mockFetchCredits = vi.fn().mockResolvedValue(undefined);
|
||||
const mockCreditsRemaining = ref<number | undefined>(undefined);
|
||||
|
|
@ -28,9 +30,9 @@ vi.mock('vue-router', async (importOriginal) => ({
|
|||
useRouter: vi.fn(() => ({})),
|
||||
}));
|
||||
|
||||
const renderComponent = createComponentRenderer(AiGatewayToggle);
|
||||
const renderComponent = createComponentRenderer(AiGatewaySelector);
|
||||
|
||||
describe('AiGatewayToggle', () => {
|
||||
describe('AiGatewaySelector', () => {
|
||||
let workflowsStore: ReturnType<typeof mockedStore<typeof useWorkflowsStore>>;
|
||||
|
||||
beforeEach(() => {
|
||||
|
|
@ -43,73 +45,87 @@ describe('AiGatewayToggle', () => {
|
|||
});
|
||||
|
||||
describe('rendering', () => {
|
||||
it('should render the toggle switch and label', () => {
|
||||
it('should render both radio cards', () => {
|
||||
renderComponent({ props: { aiGatewayEnabled: false, readonly: false } });
|
||||
|
||||
expect(screen.getByTestId('ai-gateway-toggle')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('ai-gateway-toggle-switch')).toBeInTheDocument();
|
||||
expect(screen.getByText('Connect via n8n Gateway')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('ai-gateway-selector')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('ai-gateway-selector-connect')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('ai-gateway-mode-card-own')).toBeInTheDocument();
|
||||
expect(screen.getByText('n8n Connect')).toBeInTheDocument();
|
||||
expect(screen.getByText('My own credential')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not show the callout when disabled', () => {
|
||||
renderComponent({ props: { aiGatewayEnabled: false, readonly: false } });
|
||||
|
||||
expect(
|
||||
screen.queryByText('n8n Gateway is the easy way to manage AI model usage'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show the callout when enabled', () => {
|
||||
renderComponent({ props: { aiGatewayEnabled: true, readonly: false } });
|
||||
|
||||
expect(
|
||||
screen.getByText('n8n Gateway is the easy way to manage AI model usage'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show credits badge when creditsRemaining is defined', () => {
|
||||
it('should show credits badge when aiGatewayEnabled and creditsRemaining is defined', () => {
|
||||
mockCreditsRemaining.value = 5;
|
||||
renderComponent({ props: { aiGatewayEnabled: true, readonly: false } });
|
||||
|
||||
expect(screen.getByText('5 credits remaining')).toBeInTheDocument();
|
||||
expect(screen.getByText('5 credits')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not show credits badge when creditsRemaining is undefined', () => {
|
||||
mockCreditsRemaining.value = undefined;
|
||||
renderComponent({ props: { aiGatewayEnabled: true, readonly: false } });
|
||||
|
||||
expect(screen.queryByText(/credits remaining/)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/\d+ credits$/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should disable the switch in readonly mode', () => {
|
||||
it('should not show credits badge when aiGatewayEnabled is false', () => {
|
||||
mockCreditsRemaining.value = 5;
|
||||
renderComponent({ props: { aiGatewayEnabled: false, readonly: false } });
|
||||
|
||||
expect(screen.queryByText(/\d+ credits$/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should disable both cards in readonly mode', () => {
|
||||
renderComponent({ props: { aiGatewayEnabled: false, readonly: true } });
|
||||
|
||||
expect(screen.getByTestId('ai-gateway-toggle-switch')).toBeDisabled();
|
||||
expect(screen.getByTestId('ai-gateway-selector-connect')).toBeDisabled();
|
||||
expect(screen.getByTestId('ai-gateway-mode-card-own')).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('toggle emission', () => {
|
||||
it('should emit toggle with true when switched on', async () => {
|
||||
describe('selection', () => {
|
||||
it('should emit select with true when n8n Connect card is clicked while disabled', async () => {
|
||||
const { emitted } = renderComponent({
|
||||
props: { aiGatewayEnabled: false, readonly: false },
|
||||
});
|
||||
|
||||
await userEvent.click(screen.getByTestId('ai-gateway-toggle-switch'));
|
||||
await userEvent.click(screen.getByTestId('ai-gateway-selector-connect'));
|
||||
|
||||
expect(emitted('toggle')).toBeTruthy();
|
||||
expect(emitted('toggle')![0]).toEqual([true]);
|
||||
});
|
||||
|
||||
it('should emit toggle with false when switched off', async () => {
|
||||
it('should emit select with false when own credential card is clicked while gateway is active', async () => {
|
||||
const { emitted } = renderComponent({
|
||||
props: { aiGatewayEnabled: true, readonly: false },
|
||||
});
|
||||
|
||||
await userEvent.click(screen.getByTestId('ai-gateway-toggle-switch'));
|
||||
await userEvent.click(screen.getByTestId('ai-gateway-mode-card-own'));
|
||||
|
||||
expect(emitted('toggle')).toBeTruthy();
|
||||
expect(emitted('toggle')![0]).toEqual([false]);
|
||||
});
|
||||
|
||||
it('should not emit when n8n Connect card is clicked while already selected', async () => {
|
||||
const { emitted } = renderComponent({
|
||||
props: { aiGatewayEnabled: true, readonly: false },
|
||||
});
|
||||
|
||||
await userEvent.click(screen.getByTestId('ai-gateway-selector-connect'));
|
||||
|
||||
expect(emitted('toggle')).toBeFalsy();
|
||||
});
|
||||
|
||||
it('should not emit when own credential card is clicked while already selected', async () => {
|
||||
const { emitted } = renderComponent({
|
||||
props: { aiGatewayEnabled: false, readonly: false },
|
||||
});
|
||||
|
||||
await userEvent.click(screen.getByTestId('ai-gateway-mode-card-own'));
|
||||
|
||||
expect(emitted('toggle')).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchCredits — mount watch (immediate)', () => {
|
||||
|
|
@ -175,4 +191,28 @@ describe('AiGatewayToggle', () => {
|
|||
expect(mockFetchCredits).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('top-up badge', () => {
|
||||
it('opens top-up modal when badge is clicked', async () => {
|
||||
mockCreditsRemaining.value = 5;
|
||||
renderComponent({ props: { aiGatewayEnabled: true, readonly: false } });
|
||||
|
||||
const uiStore = useUIStore();
|
||||
vi.spyOn(uiStore, 'openModalWithData');
|
||||
|
||||
await userEvent.click(screen.getByText('5 credits'));
|
||||
|
||||
expect(uiStore.openModalWithData).toHaveBeenCalledWith({
|
||||
name: AI_GATEWAY_TOP_UP_MODAL_KEY,
|
||||
data: { credentialType: undefined },
|
||||
});
|
||||
});
|
||||
|
||||
it('renders "Top up" label alongside the credits label in the badge', () => {
|
||||
mockCreditsRemaining.value = 5;
|
||||
renderComponent({ props: { aiGatewayEnabled: true, readonly: false } });
|
||||
|
||||
expect(screen.getByText('Top up')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,270 @@
|
|||
<script setup lang="ts">
|
||||
import { watch } from 'vue';
|
||||
import { N8nActionPill } from '@n8n/design-system';
|
||||
import { useI18n } from '@n8n/i18n';
|
||||
import { useAiGateway } from '@/app/composables/useAiGateway';
|
||||
import { useWorkflowsStore } from '@/app/stores/workflows.store';
|
||||
import { useUIStore } from '@/app/stores/ui.store';
|
||||
import { useTelemetry } from '@/app/composables/useTelemetry';
|
||||
import { AI_GATEWAY_TOP_UP_MODAL_KEY } from '@/app/constants';
|
||||
|
||||
const props = defineProps<{
|
||||
aiGatewayEnabled: boolean;
|
||||
readonly: boolean;
|
||||
credentialType?: string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
toggle: [enabled: boolean];
|
||||
}>();
|
||||
|
||||
const i18n = useI18n();
|
||||
const uiStore = useUIStore();
|
||||
const workflowsStore = useWorkflowsStore();
|
||||
const telemetry = useTelemetry();
|
||||
|
||||
const { creditsRemaining, fetchCredits } = useAiGateway();
|
||||
|
||||
// Fetch when enabled (on mount if already enabled, or when toggled on)
|
||||
watch(
|
||||
() => props.aiGatewayEnabled,
|
||||
(enabled) => {
|
||||
if (enabled) void fetchCredits();
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
// Refresh after each execution completes so the badge reflects consumed credits.
|
||||
watch(
|
||||
() => workflowsStore.workflowExecutionData,
|
||||
(executionData) => {
|
||||
if (
|
||||
(executionData?.finished || executionData?.stoppedAt !== undefined) &&
|
||||
props.aiGatewayEnabled
|
||||
) {
|
||||
void fetchCredits();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
function selectGateway(): void {
|
||||
if (props.readonly || props.aiGatewayEnabled) return;
|
||||
emit('toggle', true);
|
||||
}
|
||||
|
||||
function selectOwnCredential(): void {
|
||||
if (props.readonly || !props.aiGatewayEnabled) return;
|
||||
emit('toggle', false);
|
||||
}
|
||||
|
||||
function onBadgeClick(event: MouseEvent): void {
|
||||
event.stopPropagation();
|
||||
if (props.readonly) return;
|
||||
telemetry.track('User clicked ai gateway top up', {
|
||||
source: 'credential_selector',
|
||||
credential_type: props.credentialType,
|
||||
});
|
||||
uiStore.openModalWithData({
|
||||
name: AI_GATEWAY_TOP_UP_MODAL_KEY,
|
||||
data: { credentialType: props.credentialType },
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="[$style.wrapper, !aiGatewayEnabled && $style.withGap]"
|
||||
data-test-id="ai-gateway-selector"
|
||||
>
|
||||
<div role="radiogroup" :aria-label="i18n.baseText('aiGateway.credentialMode.sectionLabel')">
|
||||
<button
|
||||
type="button"
|
||||
role="radio"
|
||||
:aria-checked="aiGatewayEnabled"
|
||||
:disabled="readonly"
|
||||
data-test-id="ai-gateway-selector-connect"
|
||||
:class="[$style.card, aiGatewayEnabled ? $style.cardSelected : $style.cardIdle]"
|
||||
@click="selectGateway"
|
||||
>
|
||||
<span :class="$style.cardMain">
|
||||
<span
|
||||
:class="[$style.radioOuter, aiGatewayEnabled && $style.radioOuterOn]"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span :class="$style.textBlock">
|
||||
<span :class="$style.title">
|
||||
{{ i18n.baseText('aiGateway.credentialMode.n8nConnect.title') }}
|
||||
</span>
|
||||
<span :class="$style.subtitle">
|
||||
{{ i18n.baseText('aiGateway.credentialMode.n8nConnect.subtitle') }}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
<N8nActionPill
|
||||
v-if="aiGatewayEnabled && creditsRemaining !== undefined"
|
||||
:clickable="!readonly"
|
||||
size="small"
|
||||
:text="
|
||||
i18n.baseText('aiGateway.credentialMode.creditsShort', {
|
||||
interpolate: { count: String(creditsRemaining) },
|
||||
})
|
||||
"
|
||||
:hover-text="!readonly ? i18n.baseText('aiGateway.toggle.topUp') : undefined"
|
||||
@click="onBadgeClick"
|
||||
/>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
role="radio"
|
||||
:aria-checked="!aiGatewayEnabled"
|
||||
:disabled="readonly"
|
||||
data-test-id="ai-gateway-mode-card-own"
|
||||
:class="[$style.card, !aiGatewayEnabled ? $style.cardSelected : $style.cardIdle]"
|
||||
@click="selectOwnCredential"
|
||||
>
|
||||
<span :class="$style.cardMain">
|
||||
<span
|
||||
:class="[$style.radioOuter, !aiGatewayEnabled && $style.radioOuterOn]"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span :class="$style.textBlock">
|
||||
<span :class="$style.title">
|
||||
{{ i18n.baseText('aiGateway.credentialMode.own.title') }}
|
||||
</span>
|
||||
<span :class="$style.subtitle">
|
||||
{{ i18n.baseText('aiGateway.credentialMode.own.subtitle') }}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
.wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-top: var(--spacing--4xs);
|
||||
}
|
||||
|
||||
.withGap {
|
||||
margin-bottom: var(--spacing--2xs);
|
||||
}
|
||||
|
||||
.card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing--2xs);
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
padding: var(--spacing--2xs) var(--spacing--xs);
|
||||
border: var(--border-width) var(--border-style) var(--color--foreground);
|
||||
border-radius: var(--radius);
|
||||
cursor: pointer;
|
||||
background: transparent;
|
||||
font: inherit;
|
||||
transition:
|
||||
background-color 0.15s ease,
|
||||
border-color 0.15s ease;
|
||||
|
||||
&:not(:last-child) {
|
||||
margin-bottom: var(--spacing--2xs);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--color--primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.cardSelected {
|
||||
background-color: var(--color--foreground);
|
||||
border-color: var(--color--foreground--shade-1);
|
||||
|
||||
.title {
|
||||
color: var(--color--text--shade-1);
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: var(--color--text--tint-1);
|
||||
}
|
||||
}
|
||||
|
||||
.cardIdle {
|
||||
&:hover:not(:disabled) {
|
||||
background-color: color-mix(in srgb, var(--color--foreground) 30%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
.cardMain {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing--2xs);
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.radioOuter {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
box-sizing: border-box;
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
border-radius: 50%;
|
||||
border: var(--border-width) var(--border-style) var(--color--text--tint-2);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition:
|
||||
border-color 0.15s ease,
|
||||
background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.radioOuterOn {
|
||||
border-color: var(--color--primary);
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 0.5rem;
|
||||
height: 0.5rem;
|
||||
border-radius: 50%;
|
||||
background-color: var(--color--primary);
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
}
|
||||
|
||||
.textBlock {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: var(--spacing--3xs);
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: var(--font-size--2xs);
|
||||
font-weight: var(--font-weight--regular);
|
||||
line-height: var(--line-height--sm);
|
||||
color: var(--color--text--tint-1);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: var(--font-size--3xs);
|
||||
font-weight: var(--font-weight--regular);
|
||||
line-height: var(--line-height--sm);
|
||||
color: var(--color--text--tint-2);
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,98 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import { watch } from 'vue';
|
||||
import { N8nCallout, N8nSwitch2, N8nText } from '@n8n/design-system';
|
||||
import { useI18n } from '@n8n/i18n';
|
||||
import { useAiGateway } from '@/app/composables/useAiGateway';
|
||||
import { useWorkflowsStore } from '@/app/stores/workflows.store';
|
||||
|
||||
const props = defineProps<{
|
||||
aiGatewayEnabled: boolean;
|
||||
readonly: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
toggle: [enabled: boolean];
|
||||
}>();
|
||||
|
||||
const i18n = useI18n();
|
||||
const workflowsStore = useWorkflowsStore();
|
||||
const { creditsRemaining, fetchCredits } = useAiGateway();
|
||||
|
||||
// Fetch when enabled (on mount if already enabled, or when toggled on)
|
||||
watch(
|
||||
() => props.aiGatewayEnabled,
|
||||
(enabled) => {
|
||||
if (enabled) void fetchCredits();
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
// Refresh after each execution completes so the badge reflects consumed credits.
|
||||
// An execution is considered done when finished===true (saved runs) or stoppedAt is set
|
||||
// (step/test runs) — mirrors the same check used in workflows.store.ts.
|
||||
watch(
|
||||
() => workflowsStore.workflowExecutionData,
|
||||
(executionData) => {
|
||||
if (
|
||||
(executionData?.finished || executionData?.stoppedAt !== undefined) &&
|
||||
props.aiGatewayEnabled
|
||||
) {
|
||||
void fetchCredits();
|
||||
}
|
||||
},
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="$style.wrapper" data-test-id="ai-gateway-toggle">
|
||||
<div :class="$style.toggleRow">
|
||||
<N8nSwitch2
|
||||
:model-value="props.aiGatewayEnabled"
|
||||
data-test-id="ai-gateway-toggle-switch"
|
||||
@update:model-value="(val) => emit('toggle', Boolean(val))"
|
||||
:disabled="props.readonly"
|
||||
/>
|
||||
<N8nText size="small" color="text-dark">
|
||||
{{ i18n.baseText('aiGateway.toggle.label') }}
|
||||
</N8nText>
|
||||
</div>
|
||||
<N8nCallout v-if="props.aiGatewayEnabled" theme="success" iconless>
|
||||
{{ i18n.baseText('aiGateway.toggle.description') }}
|
||||
<template v-if="creditsRemaining !== undefined" #trailingContent>
|
||||
<span :class="$style.tokensBadge">
|
||||
{{
|
||||
i18n.baseText('aiGateway.toggle.tokensRemaining', {
|
||||
interpolate: { count: String(creditsRemaining) },
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
</template>
|
||||
</N8nCallout>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
.wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing--2xs);
|
||||
margin-top: var(--spacing--xs);
|
||||
}
|
||||
|
||||
.toggleRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing--2xs);
|
||||
}
|
||||
|
||||
.tokensBadge {
|
||||
flex-shrink: 0;
|
||||
padding: var(--spacing--4xs) var(--spacing--xs);
|
||||
border: var(--border-width) var(--border-style) var(--callout--border-color--success);
|
||||
border-radius: var(--radius);
|
||||
font-size: var(--font-size--2xs);
|
||||
font-weight: var(--font-weight--bold);
|
||||
color: var(--color--success--shade-1);
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -19,6 +19,7 @@ import { usePageRedirectionHelper } from '@/app/composables/usePageRedirectionHe
|
|||
import { useKeybindings } from '@/app/composables/useKeybindings';
|
||||
import { useSidebarLayout } from '@/app/composables/useSidebarLayout';
|
||||
import { useSettingsItems } from '@/app/composables/useSettingsItems';
|
||||
import { useAiGateway } from '@/app/composables/useAiGateway';
|
||||
import MainSidebarHeader from '@/app/components/MainSidebarHeader.vue';
|
||||
import BottomMenu from '@/app/components/BottomMenu.vue';
|
||||
import MainSidebarSourceControl from '@/app/components/MainSidebarSourceControl.vue';
|
||||
|
|
@ -51,6 +52,7 @@ const { isCollapsed, sidebarWidth, onResizeStart, onResize, onResizeEnd, toggleC
|
|||
useSidebarLayout();
|
||||
|
||||
const { settingsItems } = useSettingsItems();
|
||||
const { fetchCredits, isEnabled: isAiGatewayEnabled } = useAiGateway();
|
||||
|
||||
// Component data
|
||||
const basePath = ref('');
|
||||
|
|
@ -224,6 +226,7 @@ watch(isCollapsed, () => {
|
|||
|
||||
onMounted(() => {
|
||||
basePath.value = rootStore.baseUrl;
|
||||
if (isAiGatewayEnabled.value) void fetchCredits();
|
||||
|
||||
void nextTick(() => {
|
||||
checkOverflow();
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ import {
|
|||
CREDENTIAL_RESOLVER_EDIT_MODAL_KEY,
|
||||
AI_BUILDER_DIFF_MODAL_KEY,
|
||||
INSTANCE_AI_CREDENTIAL_SETUP_MODAL_KEY,
|
||||
AI_GATEWAY_TOP_UP_MODAL_KEY,
|
||||
} from '@/app/constants';
|
||||
import {
|
||||
ANNOTATION_TAGS_MANAGER_MODAL_KEY,
|
||||
|
|
@ -130,6 +131,7 @@ import WorkflowPublishModal from '@/app/components/MainHeader/WorkflowPublishMod
|
|||
import UpdatesPanel from './UpdatesPanel.vue';
|
||||
import CredentialResolverEditModal from '@/app/components/CredentialResolverEditModal.vue';
|
||||
import AIBuilderDiffModal from '@/features/ai/assistant/components/Agent/AIBuilderDiffModal.vue';
|
||||
import AiGatewayTopUpModal from '@/features/ai/gateway/components/AiGatewayTopUpModal.vue';
|
||||
import InstanceAiCredentialSetupModal, {
|
||||
type InstanceAiCredentialSetupModalData,
|
||||
} from '@/features/ai/instanceAi/components/InstanceAiCredentialSetupModal.vue';
|
||||
|
|
@ -501,6 +503,10 @@ import InstanceAiCredentialSetupModal, {
|
|||
</template>
|
||||
</ModalRoot>
|
||||
|
||||
<ModalRoot :name="AI_GATEWAY_TOP_UP_MODAL_KEY">
|
||||
<AiGatewayTopUpModal />
|
||||
</ModalRoot>
|
||||
|
||||
<!-- Dynamic modals from modules -->
|
||||
<DynamicModalLoader />
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
<script lang="ts" setup>
|
||||
import { onMounted } from 'vue';
|
||||
import { ABOUT_MODAL_KEY } from '@/app/constants';
|
||||
|
||||
import { N8nIcon, N8nLink, N8nMenuItem, N8nText } from '@n8n/design-system';
|
||||
import { useSettingsItems } from '../composables/useSettingsItems';
|
||||
import { useAiGateway } from '../composables/useAiGateway';
|
||||
import { useI18n } from '@n8n/i18n';
|
||||
import { useRootStore } from '@n8n/stores/useRootStore';
|
||||
import { useUIStore } from '../stores/ui.store';
|
||||
|
|
@ -16,6 +18,11 @@ const rootStore = useRootStore();
|
|||
const uiStore = useUIStore();
|
||||
|
||||
const { settingsItems } = useSettingsItems();
|
||||
const { fetchCredits, isEnabled } = useAiGateway();
|
||||
|
||||
onMounted(() => {
|
||||
if (isEnabled.value) void fetchCredits();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
|
|
|||
|
|
@ -29,30 +29,16 @@ vi.mock('@n8n/stores/useRootStore', () => ({
|
|||
}));
|
||||
|
||||
const mockIsAiGatewayEnabled = ref(false);
|
||||
const mockGetVariant = vi.fn().mockReturnValue(undefined);
|
||||
|
||||
vi.mock('@/app/stores/settings.store', () => ({
|
||||
useSettingsStore: vi.fn(() => ({ isAiGatewayEnabled: mockIsAiGatewayEnabled.value })),
|
||||
}));
|
||||
|
||||
vi.mock('@/app/stores/posthog.store', () => ({
|
||||
usePostHog: vi.fn(() => ({ getVariant: mockGetVariant })),
|
||||
}));
|
||||
|
||||
vi.mock('@/app/constants', async () => {
|
||||
const actual = await vi.importActual('@/app/constants');
|
||||
return {
|
||||
...actual,
|
||||
AI_GATEWAY_EXPERIMENT: { name: 'ai_gateway', variant: 'enabled' },
|
||||
};
|
||||
});
|
||||
|
||||
describe('useAiGateway', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia());
|
||||
vi.clearAllMocks();
|
||||
mockIsAiGatewayEnabled.value = false;
|
||||
mockGetVariant.mockReturnValue(undefined);
|
||||
mockGetGatewayConfig.mockResolvedValue({ nodes: [], credentialTypes: [], providerConfig: {} });
|
||||
});
|
||||
|
||||
|
|
@ -68,7 +54,6 @@ describe('useAiGateway', () => {
|
|||
});
|
||||
|
||||
it('should fetch and update creditsRemaining and creditsQuota when enabled', async () => {
|
||||
mockGetVariant.mockReturnValue('enabled');
|
||||
mockIsAiGatewayEnabled.value = true;
|
||||
mockGetGatewayCredits.mockResolvedValue({ creditsRemaining: 7, creditsQuota: 10 });
|
||||
|
||||
|
|
@ -82,7 +67,6 @@ describe('useAiGateway', () => {
|
|||
});
|
||||
|
||||
it('should keep previous values on API error', async () => {
|
||||
mockGetVariant.mockReturnValue('enabled');
|
||||
mockIsAiGatewayEnabled.value = true;
|
||||
|
||||
// First successful call
|
||||
|
|
@ -101,7 +85,6 @@ describe('useAiGateway', () => {
|
|||
});
|
||||
|
||||
it('should share credits state across multiple composable instances', async () => {
|
||||
mockGetVariant.mockReturnValue('enabled');
|
||||
mockIsAiGatewayEnabled.value = true;
|
||||
mockGetGatewayCredits.mockResolvedValue({ creditsRemaining: 3, creditsQuota: 5 });
|
||||
|
||||
|
|
|
|||
|
|
@ -1,14 +1,11 @@
|
|||
import { computed } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useSettingsStore } from '@/app/stores/settings.store';
|
||||
import { usePostHog } from '@/app/stores/posthog.store';
|
||||
import { AI_GATEWAY_EXPERIMENT } from '@/app/constants';
|
||||
import { useWorkflowSaving } from '@/app/composables/useWorkflowSaving';
|
||||
import { useAiGatewayStore } from '@/app/stores/aiGateway.store';
|
||||
|
||||
export function useAiGateway() {
|
||||
const settingsStore = useSettingsStore();
|
||||
const postHogStore = usePostHog();
|
||||
const router = useRouter();
|
||||
const { saveCurrentWorkflow } = useWorkflowSaving({ router });
|
||||
const aiGatewayStore = useAiGatewayStore();
|
||||
|
|
@ -17,11 +14,7 @@ export function useAiGateway() {
|
|||
const creditsQuota = computed(() => aiGatewayStore.creditsQuota);
|
||||
const fetchError = computed(() => aiGatewayStore.fetchError);
|
||||
|
||||
const isEnabled = computed(
|
||||
() =>
|
||||
postHogStore.getVariant(AI_GATEWAY_EXPERIMENT.name) === AI_GATEWAY_EXPERIMENT.variant &&
|
||||
settingsStore.isAiGatewayEnabled,
|
||||
);
|
||||
const isEnabled = computed(() => settingsStore.isAiGatewayEnabled);
|
||||
|
||||
async function fetchCredits(): Promise<void> {
|
||||
if (!isEnabled.value) return;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { useRouter } from 'vue-router';
|
||||
import { useUserHelpers } from './useUserHelpers';
|
||||
import { useAiGateway } from './useAiGateway';
|
||||
import { computed } from 'vue';
|
||||
import type { IMenuItem } from '@n8n/design-system';
|
||||
import { useI18n } from '@n8n/i18n';
|
||||
|
|
@ -8,8 +9,6 @@ import { useUIStore } from '../stores/ui.store';
|
|||
import { useSettingsStore } from '../stores/settings.store';
|
||||
import { hasPermission } from '../utils/rbac/permissions';
|
||||
import { MIGRATION_REPORT_TARGET_VERSION } from '@n8n/api-types';
|
||||
import { usePostHog } from '../stores/posthog.store';
|
||||
import { AI_GATEWAY_EXPERIMENT } from '../constants/experiments';
|
||||
|
||||
export function useSettingsItems() {
|
||||
const router = useRouter();
|
||||
|
|
@ -17,7 +16,7 @@ export function useSettingsItems() {
|
|||
const uiStore = useUIStore();
|
||||
const settingsStore = useSettingsStore();
|
||||
const { canUserAccessRouteByName } = useUserHelpers(router);
|
||||
const postHogStore = usePostHog();
|
||||
const { creditsRemaining } = useAiGateway();
|
||||
|
||||
const settingsItems = computed<IMenuItem[]>(() => {
|
||||
const menuItems: IMenuItem[] = [
|
||||
|
|
@ -55,15 +54,19 @@ export function useSettingsItems() {
|
|||
route: { to: { name: VIEWS.AI_SETTINGS } },
|
||||
},
|
||||
{
|
||||
id: 'settings-n8n-gateway',
|
||||
icon: 'network',
|
||||
label: i18n.baseText('settings.n8nGateway'),
|
||||
id: 'settings-n8n-connect',
|
||||
icon: 'plug-zap',
|
||||
label: i18n.baseText('settings.n8nConnect'),
|
||||
position: 'top',
|
||||
available:
|
||||
postHogStore.getVariant(AI_GATEWAY_EXPERIMENT.name) === AI_GATEWAY_EXPERIMENT.variant &&
|
||||
settingsStore.isAiGatewayEnabled &&
|
||||
canUserAccessRouteByName(VIEWS.AI_GATEWAY_SETTINGS),
|
||||
settingsStore.isAiGatewayEnabled && canUserAccessRouteByName(VIEWS.AI_GATEWAY_SETTINGS),
|
||||
route: { to: { name: VIEWS.AI_GATEWAY_SETTINGS } },
|
||||
creditsTag:
|
||||
creditsRemaining.value !== undefined
|
||||
? i18n.baseText('aiGateway.credentialMode.creditsShort', {
|
||||
interpolate: { count: String(creditsRemaining.value) },
|
||||
})
|
||||
: undefined,
|
||||
},
|
||||
{
|
||||
id: 'settings-project-roles',
|
||||
|
|
|
|||
|
|
@ -104,8 +104,6 @@ export const CHAT_HUB_SEMANTIC_SEARCH_EXPERIMENT = createExperiment('077_chat_hu
|
|||
|
||||
export const FLOATING_CHAT_HUB_PANEL_EXPERIMENT = createExperiment('078_floating_chat_hub_panel');
|
||||
|
||||
export const AI_GATEWAY_EXPERIMENT = createExperiment('080_ai_gateway');
|
||||
|
||||
export const EXPERIMENTS_TO_TRACK = [
|
||||
EXTRA_TEMPLATE_LINKS_EXPERIMENT.name,
|
||||
TEMPLATE_ONBOARDING_EXPERIMENT.name,
|
||||
|
|
@ -131,5 +129,4 @@ export const EXPERIMENTS_TO_TRACK = [
|
|||
AA_EXPERIMENT_CHECK.name,
|
||||
CHAT_HUB_SEMANTIC_SEARCH_EXPERIMENT.name,
|
||||
FLOATING_CHAT_HUB_PANEL_EXPERIMENT.name,
|
||||
AI_GATEWAY_EXPERIMENT.name,
|
||||
];
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ export const STOP_MANY_EXECUTIONS_MODAL_KEY = 'stopManyExecutions';
|
|||
export const WORKFLOW_EXTRACTION_NAME_MODAL_KEY = 'workflowExtractionName';
|
||||
export const WHATS_NEW_MODAL_KEY = 'whatsNew';
|
||||
export const WORKFLOW_DIFF_MODAL_KEY = 'workflowDiff';
|
||||
export const AI_GATEWAY_TOP_UP_MODAL_KEY = 'aiGatewayTopUp';
|
||||
export const EXPERIMENT_TEMPLATE_RECO_V2_KEY = 'templateRecoV2';
|
||||
export const EXPERIMENT_TEMPLATE_RECO_V3_KEY = 'templateRecoV3';
|
||||
export const BINARY_DATA_VIEW_MODAL_KEY = 'binaryDataView';
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ import { projectsRoutes } from '@/features/collaboration/projects/projects.route
|
|||
import { MfaRequiredError } from '@n8n/rest-api-client';
|
||||
import { useRecentResources } from '@/features/shared/commandBar/composables/useRecentResources';
|
||||
import { usePostHog } from '@/app/stores/posthog.store';
|
||||
import { TEMPLATE_SETUP_EXPERIENCE, AI_GATEWAY_EXPERIMENT } from '@/app/constants/experiments';
|
||||
import { TEMPLATE_SETUP_EXPERIENCE } from '@/app/constants/experiments';
|
||||
import { useDynamicCredentials } from '@/features/resolvers/composables/useDynamicCredentials';
|
||||
|
||||
const ChangePasswordView = async () =>
|
||||
|
|
@ -679,7 +679,7 @@ export const routes: RouteRecordRaw[] = [
|
|||
},
|
||||
},
|
||||
{
|
||||
path: 'n8n-gateway',
|
||||
path: 'n8n-connect',
|
||||
name: VIEWS.AI_GATEWAY_SETTINGS,
|
||||
component: SettingsAiGatewayView,
|
||||
meta: {
|
||||
|
|
@ -687,18 +687,14 @@ export const routes: RouteRecordRaw[] = [
|
|||
middlewareOptions: {
|
||||
custom: () => {
|
||||
const settingsStore = useSettingsStore();
|
||||
const postHogStore = usePostHog();
|
||||
return (
|
||||
postHogStore.getVariant(AI_GATEWAY_EXPERIMENT.name) ===
|
||||
AI_GATEWAY_EXPERIMENT.variant && settingsStore.isAiGatewayEnabled
|
||||
);
|
||||
return settingsStore.isAiGatewayEnabled;
|
||||
},
|
||||
},
|
||||
telemetry: {
|
||||
pageCategory: 'settings',
|
||||
getProperties() {
|
||||
return {
|
||||
feature: 'ai-gateway',
|
||||
feature: 'n8n-connect',
|
||||
};
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ import {
|
|||
CREDENTIAL_RESOLVER_EDIT_MODAL_KEY,
|
||||
AI_BUILDER_DIFF_MODAL_KEY,
|
||||
INSTANCE_AI_CREDENTIAL_SETUP_MODAL_KEY,
|
||||
AI_GATEWAY_TOP_UP_MODAL_KEY,
|
||||
} from '@/app/constants';
|
||||
import {
|
||||
ANNOTATION_TAGS_MANAGER_MODAL_KEY,
|
||||
|
|
@ -173,6 +174,7 @@ export const useUIStore = defineStore(STORES.UI, () => {
|
|||
CREDENTIAL_RESOLVER_EDIT_MODAL_KEY,
|
||||
AI_BUILDER_DIFF_MODAL_KEY,
|
||||
INSTANCE_AI_CREDENTIAL_SETUP_MODAL_KEY,
|
||||
AI_GATEWAY_TOP_UP_MODAL_KEY,
|
||||
].map((modalKey) => [modalKey, { open: false }]),
|
||||
),
|
||||
[DELETE_USER_MODAL_KEY]: {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,85 @@
|
|||
import { describe, it, vi, beforeEach, expect } from 'vitest';
|
||||
import { screen } from '@testing-library/vue';
|
||||
import { createTestingPinia } from '@pinia/testing';
|
||||
import { setActivePinia } from 'pinia';
|
||||
import AiGatewayTopUpModal from './AiGatewayTopUpModal.vue';
|
||||
import { createComponentRenderer } from '@/__tests__/render';
|
||||
import { useUIStore } from '@/app/stores/ui.store';
|
||||
import { useCredentialsStore } from '@/features/credentials/credentials.store';
|
||||
import { AI_GATEWAY_TOP_UP_MODAL_KEY } from '@/app/constants';
|
||||
|
||||
vi.mock('@/app/components/Modal.vue', () => ({
|
||||
default: {
|
||||
props: ['name', 'title'],
|
||||
template:
|
||||
'<div :data-modal-name="name" :data-modal-title="title"><slot name="content" /><slot name="footer" /></div>',
|
||||
},
|
||||
}));
|
||||
|
||||
const renderComponent = createComponentRenderer(AiGatewayTopUpModal);
|
||||
|
||||
function renderModal() {
|
||||
const pinia = createTestingPinia();
|
||||
setActivePinia(pinia);
|
||||
return { ...renderComponent({ pinia }), pinia };
|
||||
}
|
||||
|
||||
function renderModalWithCredentialType(
|
||||
credentialTypeName: string,
|
||||
options: { displayName?: string; documentationUrl?: string } = {},
|
||||
) {
|
||||
const pinia = createTestingPinia();
|
||||
setActivePinia(pinia);
|
||||
const uiStore = useUIStore();
|
||||
uiStore.modalsById[AI_GATEWAY_TOP_UP_MODAL_KEY] = {
|
||||
open: true,
|
||||
data: { credentialType: credentialTypeName },
|
||||
};
|
||||
const credStore = useCredentialsStore();
|
||||
credStore.state.credentialTypes[credentialTypeName] = {
|
||||
name: credentialTypeName,
|
||||
displayName: options.displayName ?? credentialTypeName,
|
||||
documentationUrl: options.documentationUrl,
|
||||
properties: [],
|
||||
};
|
||||
return renderComponent({ pinia });
|
||||
}
|
||||
|
||||
describe('AiGatewayTopUpModal.vue', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('shows the coming-soon content', () => {
|
||||
renderModal();
|
||||
expect(screen.getByText('Credit top up is coming soon')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render any buy UI or footer buttons', () => {
|
||||
renderModal();
|
||||
expect(screen.queryByTestId('ai-gateway-topup-preset')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('ai-gateway-topup-custom')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('ai-gateway-topup-buy')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe('credentials docs link', () => {
|
||||
it('does not show docs link when no credentialType is provided', () => {
|
||||
renderModal();
|
||||
expect(
|
||||
screen.queryByTestId('ai-gateway-topup-credentials-docs-link'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows descriptive docs link with credential display name', () => {
|
||||
renderModalWithCredentialType('openAiApi', { displayName: 'OpenAI' });
|
||||
const link = screen.getByTestId('ai-gateway-topup-credentials-docs-link');
|
||||
expect(link).toBeInTheDocument();
|
||||
expect(link.textContent).toContain('See how to configure the OpenAI credential');
|
||||
});
|
||||
|
||||
it('shows docs link when credentialType has a documentationUrl', () => {
|
||||
renderModalWithCredentialType('openAiApi', { documentationUrl: 'openai' });
|
||||
expect(screen.getByTestId('ai-gateway-topup-credentials-docs-link')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,127 @@
|
|||
<script lang="ts" setup>
|
||||
import { computed } from 'vue';
|
||||
import { N8nIcon, N8nLink, N8nText } from '@n8n/design-system';
|
||||
import { useUIStore } from '@/app/stores/ui.store';
|
||||
import { useCredentialsStore } from '@/features/credentials/credentials.store';
|
||||
import { BUILTIN_CREDENTIALS_DOCS_URL } from '@/app/constants/urls';
|
||||
import { AI_GATEWAY_TOP_UP_MODAL_KEY } from '@/app/constants';
|
||||
import Modal from '@/app/components/Modal.vue';
|
||||
|
||||
const uiStore = useUIStore();
|
||||
const credentialsStore = useCredentialsStore();
|
||||
|
||||
const modalData = computed(() => uiStore.modalsById[AI_GATEWAY_TOP_UP_MODAL_KEY]?.data);
|
||||
const credentialType = computed<string | undefined>(
|
||||
() => modalData.value?.credentialType as string | undefined,
|
||||
);
|
||||
|
||||
const credentialTypeInfo = computed(() => {
|
||||
if (!credentialType.value) return null;
|
||||
return credentialsStore.getCredentialTypeByName(credentialType.value) ?? null;
|
||||
});
|
||||
|
||||
const credentialDocsUrl = computed(() => {
|
||||
const type = credentialTypeInfo.value;
|
||||
if (!type?.documentationUrl) return '';
|
||||
|
||||
if (type.documentationUrl.startsWith('http')) {
|
||||
return type.documentationUrl;
|
||||
}
|
||||
|
||||
return `${BUILTIN_CREDENTIALS_DOCS_URL}${type.documentationUrl}/`;
|
||||
});
|
||||
|
||||
const credentialDocsLinkText = computed(() => {
|
||||
const name = credentialTypeInfo.value?.displayName;
|
||||
return name ? `See how to configure the ${name} credential` : 'See credential setup guide';
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal
|
||||
:name="AI_GATEWAY_TOP_UP_MODAL_KEY"
|
||||
width="520px"
|
||||
custom-class="ai-gateway-topup-dialog"
|
||||
data-test-id="ai-gateway-topup-modal"
|
||||
>
|
||||
<template #content>
|
||||
<div :class="$style.contentWrapper">
|
||||
<div :class="$style.body">
|
||||
<N8nIcon icon="hourglass" size="xlarge" color="text-base" :class="$style.icon" />
|
||||
<N8nText :class="$style.title" bold color="text-dark"
|
||||
>Credit top up is coming soon</N8nText
|
||||
>
|
||||
|
||||
<div :class="$style.paragraphs">
|
||||
<p :class="$style.paragraph">
|
||||
You'll be notified in the coming weeks when this feature becomes available.
|
||||
</p>
|
||||
<p :class="$style.paragraph">
|
||||
In the meantime you can switch to using your own credentials.
|
||||
</p>
|
||||
<p v-if="credentialType" :class="$style.paragraph">
|
||||
<N8nLink
|
||||
:to="credentialDocsUrl"
|
||||
new-window
|
||||
data-test-id="ai-gateway-topup-credentials-docs-link"
|
||||
>
|
||||
{{ credentialDocsLinkText }}
|
||||
</N8nLink>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
.contentWrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
min-height: 300px;
|
||||
}
|
||||
|
||||
.body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
gap: var(--spacing--sm);
|
||||
padding: var(--spacing--sm) 0 var(--spacing--xl);
|
||||
}
|
||||
|
||||
.icon {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: var(--font-size--lg);
|
||||
font-weight: var(--font-weight--bold);
|
||||
line-height: var(--line-height--md);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.paragraphs {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing--sm);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.paragraph {
|
||||
margin: 0;
|
||||
font-size: var(--font-size--sm);
|
||||
line-height: var(--line-height--md);
|
||||
font-weight: var(--font-weight--regular);
|
||||
color: var(--color--text--tint-1);
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="scss">
|
||||
.ai-gateway-topup-dialog.el-dialog {
|
||||
background-color: var(--color--background);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -4,12 +4,16 @@ import { screen, waitFor } from '@testing-library/vue';
|
|||
import userEvent from '@testing-library/user-event';
|
||||
import SettingsAiGatewayView from './SettingsAiGatewayView.vue';
|
||||
import { createComponentRenderer } from '@/__tests__/render';
|
||||
import { useUIStore } from '@/app/stores/ui.store';
|
||||
import { useAiGatewayStore } from '@/app/stores/aiGateway.store';
|
||||
import { AI_GATEWAY_TOP_UP_MODAL_KEY } from '@/app/constants';
|
||||
|
||||
const mockGetGatewayUsage = vi.fn();
|
||||
const mockGetGatewayCredits = vi.fn();
|
||||
|
||||
vi.mock('@/features/ai/assistant/assistant.api', () => ({
|
||||
getGatewayConfig: vi.fn(),
|
||||
getGatewayCredits: vi.fn(),
|
||||
getGatewayCredits: (...args: unknown[]) => mockGetGatewayCredits(...args),
|
||||
getGatewayUsage: (...args: unknown[]) => mockGetGatewayUsage(...args),
|
||||
}));
|
||||
|
||||
|
|
@ -49,6 +53,45 @@ describe('SettingsAiGatewayView', () => {
|
|||
vi.clearAllMocks();
|
||||
setActivePinia(createPinia());
|
||||
mockGetGatewayUsage.mockResolvedValue({ entries: [], total: 0 });
|
||||
mockGetGatewayCredits.mockResolvedValue({ creditsRemaining: 42, creditsQuota: 100 });
|
||||
});
|
||||
|
||||
describe('credits card', () => {
|
||||
it('should display creditsRemaining after fetching', async () => {
|
||||
renderComponent();
|
||||
|
||||
await waitFor(() => expect(screen.getByTestId('settings-ai-gateway')).toBeInTheDocument());
|
||||
const store = useAiGatewayStore();
|
||||
await waitFor(() => expect(store.creditsRemaining).toBe(42));
|
||||
expect(screen.getByText('42 credits')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not render the credits number before data loads', () => {
|
||||
mockGetGatewayCredits.mockReturnValue(new Promise(() => {})); // never resolves
|
||||
renderComponent();
|
||||
|
||||
expect(screen.queryByTestId('ai-gateway-topup-button')).not.toBeNull(); // button present
|
||||
// number not yet visible (creditsRemaining undefined)
|
||||
expect(screen.queryByText('42')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should open top-up modal when "Top up credits" button is clicked', async () => {
|
||||
renderComponent();
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId('ai-gateway-topup-button')).toBeInTheDocument(),
|
||||
);
|
||||
|
||||
const uiStore = useUIStore();
|
||||
vi.spyOn(uiStore, 'openModalWithData');
|
||||
|
||||
await userEvent.click(screen.getByTestId('ai-gateway-topup-button'));
|
||||
|
||||
expect(uiStore.openModalWithData).toHaveBeenCalledWith({
|
||||
name: AI_GATEWAY_TOP_UP_MODAL_KEY,
|
||||
data: {},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('on mount', () => {
|
||||
|
|
@ -98,7 +141,7 @@ describe('SettingsAiGatewayView', () => {
|
|||
it('should re-fetch from offset=0 when refresh is clicked', async () => {
|
||||
mockGetGatewayUsage.mockResolvedValue({ entries: MOCK_ENTRIES, total: 100 });
|
||||
renderComponent();
|
||||
await waitFor(() => expect(mockGetGatewayUsage).toHaveBeenCalledOnce());
|
||||
await waitFor(() => expect(screen.getByText('gemini-pro')).toBeInTheDocument());
|
||||
|
||||
mockGetGatewayUsage.mockClear();
|
||||
await userEvent.click(screen.getByRole('button', { name: /refresh/i }));
|
||||
|
|
|
|||
|
|
@ -1,22 +1,94 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { N8nHeading, N8nText, N8nButton } from '@n8n/design-system';
|
||||
import {
|
||||
N8nActionBox,
|
||||
N8nButton,
|
||||
N8nDataTableServer,
|
||||
N8nHeading,
|
||||
N8nLoading,
|
||||
N8nText,
|
||||
N8nTooltip,
|
||||
N8nActionPill,
|
||||
} from '@n8n/design-system';
|
||||
import type { TableHeader } from '@n8n/design-system/components/N8nDataTableServer';
|
||||
import type { AiGatewayUsageEntry } from '@n8n/api-types';
|
||||
import { useI18n } from '@n8n/i18n';
|
||||
import { useDocumentTitle } from '@/app/composables/useDocumentTitle';
|
||||
import { useTelemetry } from '@/app/composables/useTelemetry';
|
||||
import { useAiGatewayStore } from '@/app/stores/aiGateway.store';
|
||||
import { useUIStore } from '@/app/stores/ui.store';
|
||||
import { AI_GATEWAY_TOP_UP_MODAL_KEY } from '@/app/constants';
|
||||
|
||||
const i18n = useI18n();
|
||||
const documentTitle = useDocumentTitle();
|
||||
const telemetry = useTelemetry();
|
||||
const aiGatewayStore = useAiGatewayStore();
|
||||
const uiStore = useUIStore();
|
||||
|
||||
const isLoading = ref(false);
|
||||
const isAppending = ref(false);
|
||||
const offset = ref(0);
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
const creditsRemaining = computed(() => aiGatewayStore.creditsRemaining);
|
||||
const creditsBadgeText = computed(() =>
|
||||
creditsRemaining.value !== undefined
|
||||
? i18n.baseText('aiGateway.credentialMode.creditsShort', {
|
||||
interpolate: { count: String(creditsRemaining.value) },
|
||||
})
|
||||
: undefined,
|
||||
);
|
||||
const entries = computed(() => aiGatewayStore.usageEntries);
|
||||
const total = computed(() => aiGatewayStore.usageTotal);
|
||||
const hasMore = computed(() => offset.value + PAGE_SIZE < total.value);
|
||||
|
||||
const showUsageSectionSkeleton = computed(() => isLoading.value && !isAppending.value);
|
||||
|
||||
const tableHeaders = ref<Array<TableHeader<AiGatewayUsageEntry>>>([
|
||||
{
|
||||
title: i18n.baseText('settings.n8nConnect.usage.col.date'),
|
||||
key: 'timestamp',
|
||||
width: 200,
|
||||
disableSort: true,
|
||||
resize: false,
|
||||
},
|
||||
{
|
||||
title: i18n.baseText('settings.n8nConnect.usage.col.provider'),
|
||||
key: 'provider',
|
||||
width: 120,
|
||||
disableSort: true,
|
||||
resize: false,
|
||||
},
|
||||
{
|
||||
title: i18n.baseText('settings.n8nConnect.usage.col.model'),
|
||||
key: 'model',
|
||||
width: 220,
|
||||
disableSort: true,
|
||||
resize: false,
|
||||
},
|
||||
{
|
||||
title: i18n.baseText('settings.n8nConnect.usage.col.inputTokens'),
|
||||
key: 'inputTokens',
|
||||
width: 120,
|
||||
disableSort: true,
|
||||
resize: false,
|
||||
},
|
||||
{
|
||||
title: i18n.baseText('settings.n8nConnect.usage.col.outputTokens'),
|
||||
key: 'outputTokens',
|
||||
width: 120,
|
||||
disableSort: true,
|
||||
resize: false,
|
||||
},
|
||||
{
|
||||
title: i18n.baseText('settings.n8nConnect.usage.col.credits'),
|
||||
key: 'creditsDeducted',
|
||||
width: 100,
|
||||
disableSort: true,
|
||||
resize: false,
|
||||
},
|
||||
]);
|
||||
|
||||
function formatDate(timestamp: number): string {
|
||||
return new Intl.DateTimeFormat(undefined, {
|
||||
day: '2-digit',
|
||||
|
|
@ -31,7 +103,12 @@ function formatTokens(value?: number): string {
|
|||
return value !== undefined ? String(value) : '—';
|
||||
}
|
||||
|
||||
function rowId(row: AiGatewayUsageEntry, index: number): string {
|
||||
return `${row.timestamp}-${row.model}-${row.provider}-${index}`;
|
||||
}
|
||||
|
||||
async function load(): Promise<void> {
|
||||
isAppending.value = false;
|
||||
offset.value = 0;
|
||||
isLoading.value = true;
|
||||
try {
|
||||
|
|
@ -47,83 +124,119 @@ async function refresh(): Promise<void> {
|
|||
|
||||
async function loadMore(): Promise<void> {
|
||||
if (isLoading.value) return;
|
||||
isAppending.value = true;
|
||||
offset.value += PAGE_SIZE;
|
||||
isLoading.value = true;
|
||||
try {
|
||||
await aiGatewayStore.fetchMoreUsage(offset.value, PAGE_SIZE);
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
isAppending.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
documentTitle.set(i18n.baseText('settings.n8nGateway.title'));
|
||||
await load();
|
||||
documentTitle.set(i18n.baseText('settings.n8nConnect.title'));
|
||||
await Promise.all([aiGatewayStore.fetchCredits(), load()]);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="$style.container" data-test-id="settings-ai-gateway">
|
||||
<div :class="$style.header">
|
||||
<N8nHeading size="2xlarge">{{ i18n.baseText('settings.n8nGateway.title') }}</N8nHeading>
|
||||
<N8nText size="small" color="text-light">
|
||||
{{ i18n.baseText('settings.n8nGateway.description') }}
|
||||
</N8nText>
|
||||
</div>
|
||||
|
||||
<div :class="$style.section">
|
||||
<div :class="$style.sectionHeader">
|
||||
<N8nHeading size="large">{{ i18n.baseText('settings.n8nGateway.usage.title') }}</N8nHeading>
|
||||
<N8nButton
|
||||
:label="i18n.baseText('settings.n8nGateway.usage.refresh')"
|
||||
icon="refresh-cw"
|
||||
type="secondary"
|
||||
:loading="isLoading"
|
||||
@click="refresh"
|
||||
/>
|
||||
<header :class="$style.mainHeader" data-test-id="ai-gateway-settings-header">
|
||||
<div :class="$style.headings">
|
||||
<div :class="$style.headingRow">
|
||||
<N8nHeading size="2xlarge">{{ i18n.baseText('settings.n8nConnect.title') }}</N8nHeading>
|
||||
<N8nActionPill
|
||||
v-if="creditsBadgeText"
|
||||
size="medium"
|
||||
:text="creditsBadgeText"
|
||||
data-test-id="ai-gateway-header-credits-badge"
|
||||
/>
|
||||
</div>
|
||||
<N8nText size="small" color="text-light">
|
||||
{{ i18n.baseText('settings.n8nConnect.description') }}
|
||||
</N8nText>
|
||||
</div>
|
||||
<N8nButton
|
||||
:label="i18n.baseText('settings.n8nConnect.credits.topUp')"
|
||||
icon="hand-coins"
|
||||
variant="solid"
|
||||
data-test-id="ai-gateway-topup-button"
|
||||
@click="
|
||||
telemetry.track('User clicked ai gateway top up', { source: 'settings_page' });
|
||||
uiStore.openModalWithData({ name: AI_GATEWAY_TOP_UP_MODAL_KEY, data: {} });
|
||||
"
|
||||
/>
|
||||
</header>
|
||||
|
||||
<div :class="$style.tableWrapper">
|
||||
<table :class="$style.table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ i18n.baseText('settings.n8nGateway.usage.col.date') }}</th>
|
||||
<th>{{ i18n.baseText('settings.n8nGateway.usage.col.provider') }}</th>
|
||||
<th>{{ i18n.baseText('settings.n8nGateway.usage.col.model') }}</th>
|
||||
<th>{{ i18n.baseText('settings.n8nGateway.usage.col.inputTokens') }}</th>
|
||||
<th>{{ i18n.baseText('settings.n8nGateway.usage.col.outputTokens') }}</th>
|
||||
<th>{{ i18n.baseText('settings.n8nGateway.usage.col.credits') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-if="entries.length === 0 && !isLoading">
|
||||
<td colspan="6" :class="$style.empty">
|
||||
{{ i18n.baseText('settings.n8nGateway.usage.empty') }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-for="(entry, idx) in entries" :key="idx">
|
||||
<td>{{ formatDate(entry.timestamp) }}</td>
|
||||
<td>
|
||||
<span :class="$style.badge">
|
||||
{{ entry.provider }}
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ entry.model }}</td>
|
||||
<td>{{ formatTokens(entry.inputTokens) }}</td>
|
||||
<td>{{ formatTokens(entry.outputTokens) }}</td>
|
||||
<td>{{ entry.creditsDeducted }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div :class="$style.usageTableContainer">
|
||||
<div v-if="showUsageSectionSkeleton">
|
||||
<N8nLoading :loading="true" variant="h1" :class="$style.usageTableHeader" />
|
||||
<N8nLoading :loading="true" variant="p" :rows="5" :shrink-last="false" />
|
||||
</div>
|
||||
|
||||
<div v-if="hasMore" :class="$style.loadMore">
|
||||
<N8nButton
|
||||
:label="i18n.baseText('settings.n8nGateway.usage.loadMore')"
|
||||
type="secondary"
|
||||
:loading="isLoading"
|
||||
@click="loadMore"
|
||||
<div v-else>
|
||||
<div :class="$style.usageTableHeader">
|
||||
<N8nHeading size="medium" :bold="true">
|
||||
{{ i18n.baseText('settings.n8nConnect.usage.title') }}
|
||||
</N8nHeading>
|
||||
<div :class="$style.usageTableActions">
|
||||
<N8nTooltip :content="i18n.baseText('settings.n8nConnect.usage.refresh.tooltip')">
|
||||
<N8nButton
|
||||
variant="subtle"
|
||||
icon-only
|
||||
size="small"
|
||||
icon="refresh-cw"
|
||||
:aria-label="i18n.baseText('generic.refresh')"
|
||||
:loading="isLoading && !isAppending"
|
||||
@click="refresh"
|
||||
/>
|
||||
</N8nTooltip>
|
||||
</div>
|
||||
</div>
|
||||
<N8nActionBox
|
||||
v-if="entries.length === 0"
|
||||
:heading="i18n.baseText('settings.n8nConnect.usage.empty')"
|
||||
/>
|
||||
<N8nDataTableServer
|
||||
v-else
|
||||
:class="$style.gatewayUsageTable"
|
||||
:headers="tableHeaders"
|
||||
:items="entries"
|
||||
:items-length="entries.length"
|
||||
:loading="isLoading && isAppending"
|
||||
:item-value="rowId"
|
||||
>
|
||||
<template #[`item.timestamp`]="{ item }">
|
||||
{{ formatDate(item.timestamp) }}
|
||||
</template>
|
||||
<template #[`item.provider`]="{ item }">
|
||||
<span :class="$style.providerBadge">
|
||||
{{ item.provider }}
|
||||
</span>
|
||||
</template>
|
||||
<template #[`item.model`]="{ item }">
|
||||
{{ item.model }}
|
||||
</template>
|
||||
<template #[`item.inputTokens`]="{ item }">
|
||||
{{ formatTokens(item.inputTokens) }}
|
||||
</template>
|
||||
<template #[`item.outputTokens`]="{ item }">
|
||||
{{ formatTokens(item.outputTokens) }}
|
||||
</template>
|
||||
<template #[`item.creditsDeducted`]="{ item }">
|
||||
{{ item.creditsDeducted }}
|
||||
</template>
|
||||
</N8nDataTableServer>
|
||||
|
||||
<div v-if="hasMore && entries.length > 0" :class="$style.loadMore">
|
||||
<N8nButton
|
||||
:label="i18n.baseText('settings.n8nConnect.usage.loadMore')"
|
||||
type="secondary"
|
||||
:loading="isLoading && isAppending"
|
||||
@click="loadMore"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -133,66 +246,63 @@ onMounted(async () => {
|
|||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing--xl);
|
||||
gap: var(--spacing--lg);
|
||||
padding-bottom: var(--spacing--2xl);
|
||||
}
|
||||
|
||||
.header {
|
||||
.mainHeader {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: var(--spacing--md);
|
||||
|
||||
@media (max-width: 820px) {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
.headings {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing--2xs);
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing--sm);
|
||||
}
|
||||
|
||||
.sectionHeader {
|
||||
.headingRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing--2xs);
|
||||
margin-bottom: var(--spacing--5xs);
|
||||
}
|
||||
|
||||
.usageTableContainer {
|
||||
:global(.table-pagination) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.usageTableHeader {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: var(--spacing--sm);
|
||||
}
|
||||
|
||||
.tableWrapper {
|
||||
border: var(--border);
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
.usageTableActions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing--xs);
|
||||
}
|
||||
|
||||
.table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: var(--font-size--sm);
|
||||
|
||||
th {
|
||||
text-align: left;
|
||||
padding: var(--spacing--xs) var(--spacing--sm);
|
||||
color: var(--color--text--tint-1);
|
||||
font-weight: var(--font-weight--bold);
|
||||
border-bottom: var(--border);
|
||||
background-color: var(--color--background);
|
||||
}
|
||||
|
||||
td {
|
||||
padding: var(--spacing--xs) var(--spacing--sm);
|
||||
color: var(--color--text);
|
||||
border-bottom: var(--border);
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
tbody tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.empty {
|
||||
text-align: center;
|
||||
color: var(--color--text--tint-2);
|
||||
padding: var(--spacing--xl);
|
||||
.gatewayUsageTable {
|
||||
tr:last-child {
|
||||
border-bottom: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
.badge {
|
||||
.providerBadge {
|
||||
display: inline-block;
|
||||
padding: var(--spacing--5xs) var(--spacing--2xs);
|
||||
border-radius: var(--radius);
|
||||
|
|
@ -205,6 +315,6 @@ onMounted(async () => {
|
|||
.loadMore {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding-top: var(--spacing--xs);
|
||||
padding-top: var(--spacing--md);
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -51,7 +51,7 @@ const isHovered = useElementHover(triggerRef);
|
|||
|
||||
<style lang="scss" module>
|
||||
.reasoningTrigger {
|
||||
color: var(--text-color--subtler);
|
||||
color: var(--color--text--tint-2);
|
||||
}
|
||||
|
||||
.reasoningContent {
|
||||
|
|
|
|||
|
|
@ -77,7 +77,7 @@ function handleClick(e: MouseEvent) {
|
|||
}
|
||||
|
||||
.metadata {
|
||||
color: var(--text-color--subtler);
|
||||
color: var(--color--text--tint-2);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
|
|
|
|||
|
|
@ -867,7 +867,7 @@ describe('NodeCredentials', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('AI Gateway toggle (onAiGatewayToggle)', () => {
|
||||
describe('AI Gateway toggle (onAiGatewaySelector)', () => {
|
||||
const googlePalmApiCredType: ICredentialType = {
|
||||
name: 'googlePalmApi',
|
||||
displayName: 'Google PaLM API',
|
||||
|
|
@ -943,7 +943,7 @@ describe('NodeCredentials', () => {
|
|||
|
||||
renderComponent({
|
||||
props: { node: nodeWithCred, overrideCredType: 'googlePalmApi' },
|
||||
global: { stubs: { AiGatewayToggle: aiGatewayToggleStub } },
|
||||
global: { stubs: { AiGatewaySelector: aiGatewayToggleStub } },
|
||||
});
|
||||
|
||||
// Both the toggle and the credential dropdown should be visible
|
||||
|
|
@ -960,7 +960,7 @@ describe('NodeCredentials', () => {
|
|||
|
||||
renderComponent({
|
||||
props: { node: nodeWithGateway, overrideCredType: 'googlePalmApi' },
|
||||
global: { stubs: { AiGatewayToggle: aiGatewayToggleStub } },
|
||||
global: { stubs: { AiGatewaySelector: aiGatewayToggleStub } },
|
||||
});
|
||||
|
||||
expect(screen.getByTestId('ai-gateway-toggle')).toBeInTheDocument();
|
||||
|
|
@ -990,7 +990,7 @@ describe('NodeCredentials', () => {
|
|||
|
||||
renderComponent({
|
||||
props: { node: nodeWithGateway, overrideCredType: 'googlePalmApi' },
|
||||
global: { stubs: { AiGatewayToggle: aiGatewayToggleStub } },
|
||||
global: { stubs: { AiGatewaySelector: aiGatewayToggleStub } },
|
||||
});
|
||||
|
||||
expect(screen.getByTestId('ai-gateway-toggle')).toBeInTheDocument();
|
||||
|
|
@ -1012,7 +1012,7 @@ describe('NodeCredentials', () => {
|
|||
|
||||
renderComponent({
|
||||
props: { node: googleAiNode, overrideCredType: 'googlePalmApi' },
|
||||
global: { stubs: { AiGatewayToggle: aiGatewayToggleStub } },
|
||||
global: { stubs: { AiGatewaySelector: aiGatewayToggleStub } },
|
||||
});
|
||||
|
||||
expect(screen.queryByTestId('ai-gateway-toggle')).not.toBeInTheDocument();
|
||||
|
|
@ -1027,7 +1027,7 @@ describe('NodeCredentials', () => {
|
|||
|
||||
renderComponent({
|
||||
props: { node: nodeWithGateway, overrideCredType: 'googlePalmApi', readonly: true },
|
||||
global: { stubs: { AiGatewayToggle: aiGatewayToggleStub } },
|
||||
global: { stubs: { AiGatewaySelector: aiGatewayToggleStub } },
|
||||
});
|
||||
|
||||
expect(screen.getByTestId('ai-gateway-toggle')).toBeInTheDocument();
|
||||
|
|
@ -1044,7 +1044,7 @@ describe('NodeCredentials', () => {
|
|||
|
||||
renderComponent({
|
||||
props: { node: nodeWithCred, overrideCredType: 'googlePalmApi', readonly: true },
|
||||
global: { stubs: { AiGatewayToggle: aiGatewayToggleStub } },
|
||||
global: { stubs: { AiGatewaySelector: aiGatewayToggleStub } },
|
||||
});
|
||||
|
||||
// Toggle is shown (disabled) so users can see the gateway is supported for this type
|
||||
|
|
@ -1060,7 +1060,7 @@ describe('NodeCredentials', () => {
|
|||
props: { node: googleAiNode, overrideCredType: 'googlePalmApi' },
|
||||
global: {
|
||||
stubs: {
|
||||
AiGatewayToggle: {
|
||||
AiGatewaySelector: {
|
||||
template:
|
||||
'<button data-test-id="ai-gateway-toggle-on" @click="$emit(\'toggle\', true)" />',
|
||||
props: ['aiGatewayEnabled'],
|
||||
|
|
@ -1107,7 +1107,7 @@ describe('NodeCredentials', () => {
|
|||
props: { node: nodeWithGateway, overrideCredType: 'googlePalmApi' },
|
||||
global: {
|
||||
stubs: {
|
||||
AiGatewayToggle: {
|
||||
AiGatewaySelector: {
|
||||
template:
|
||||
'<button data-test-id="ai-gateway-toggle-off" @click="$emit(\'toggle\', false)" />',
|
||||
props: ['aiGatewayEnabled'],
|
||||
|
|
@ -1146,7 +1146,7 @@ describe('NodeCredentials', () => {
|
|||
props: { node: nodeWithGateway, overrideCredType: 'googlePalmApi' },
|
||||
global: {
|
||||
stubs: {
|
||||
AiGatewayToggle: {
|
||||
AiGatewaySelector: {
|
||||
template:
|
||||
'<button data-test-id="ai-gateway-toggle-off" @click="$emit(\'toggle\', false)" />',
|
||||
props: ['aiGatewayEnabled'],
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@ import { getResourcePermissions } from '@n8n/permissions';
|
|||
import { useNodeCredentialOptions } from '../composables/useNodeCredentialOptions';
|
||||
import { useDynamicCredentials } from '@/features/resolvers/composables/useDynamicCredentials';
|
||||
import { useAiGateway } from '@/app/composables/useAiGateway';
|
||||
import AiGatewayToggle from '@/app/components/AiGatewayToggle.vue';
|
||||
import AiGatewaySelector from '@/app/components/AiGatewaySelector.vue';
|
||||
|
||||
import {
|
||||
N8nBadge,
|
||||
|
|
@ -215,7 +215,17 @@ watch(
|
|||
|
||||
const allOptions = types.map((type) => type.options).flat();
|
||||
|
||||
if (allOptions.length === 0) return;
|
||||
if (allOptions.length === 0) {
|
||||
// No credentials configured — auto-enable AI Gateway for supported types
|
||||
if (aiGateway.isEnabled.value) {
|
||||
for (const { type } of types) {
|
||||
if (aiGateway.isCredentialTypeSupported(type.name)) {
|
||||
onAiGatewaySelector(type.name, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const mostRecentCredential = allOptions.reduce(
|
||||
(mostRecent, current) =>
|
||||
|
|
@ -513,14 +523,14 @@ function isAiGatewayManagedCredentials(credentialType: string): boolean {
|
|||
return aiGateway.isEnabled.value && selected.value[credentialType]?.__aiGatewayManaged === true;
|
||||
}
|
||||
|
||||
function showAiGatewayToggle(credentialType: string): boolean {
|
||||
function showAiGatewaySelector(credentialType: string): boolean {
|
||||
if (!aiGateway.isEnabled.value) return false;
|
||||
if (isAiGatewayManagedCredentials(credentialType)) return true;
|
||||
if (!aiGateway.isCredentialTypeSupported(credentialType)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function onAiGatewayToggle(credentialType: string, enable: boolean): void {
|
||||
function onAiGatewaySelector(credentialType: string, enable: boolean): void {
|
||||
const credentials = { ...(props.node.credentials ?? {}) };
|
||||
|
||||
if (enable) {
|
||||
|
|
@ -670,6 +680,13 @@ async function onQuickConnectSignIn(credentialTypeName: string) {
|
|||
<template v-if="$slots['label-postfix']" #options>
|
||||
<slot name="label-postfix" />
|
||||
</template>
|
||||
<AiGatewaySelector
|
||||
v-if="showAiGatewaySelector(type.name)"
|
||||
:ai-gateway-enabled="isAiGatewayManagedCredentials(type.name)"
|
||||
:readonly="readonly"
|
||||
:credential-type="type.name"
|
||||
@toggle="onAiGatewaySelector(type.name, $event)"
|
||||
/>
|
||||
<div v-if="readonly && !isAiGatewayManagedCredentials(type.name)">
|
||||
<N8nInput
|
||||
:model-value="getSelectedName(type.name)"
|
||||
|
|
@ -859,12 +876,6 @@ async function onQuickConnectSignIn(credentialTypeName: string) {
|
|||
</I18nT>
|
||||
</N8nNotice>
|
||||
</N8nInputLabel>
|
||||
<AiGatewayToggle
|
||||
v-if="showAiGatewayToggle(type.name)"
|
||||
:ai-gateway-enabled="isAiGatewayManagedCredentials(type.name)"
|
||||
:readonly="readonly"
|
||||
@toggle="onAiGatewayToggle(type.name, $event)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -17,7 +17,6 @@ import {
|
|||
AI_CATEGORY_VECTOR_STORES,
|
||||
AI_SUBCATEGORY,
|
||||
AI_TRANSFORM_NODE_TYPE,
|
||||
AI_GATEWAY_EXPERIMENT,
|
||||
BETA_NODES,
|
||||
CORE_NODES_CATEGORY,
|
||||
DEFAULT_SUBCATEGORY,
|
||||
|
|
@ -37,7 +36,6 @@ import type { NodeViewItemSection } from './views/viewsData';
|
|||
|
||||
import { useAiGatewayStore } from '@/app/stores/aiGateway.store';
|
||||
import { useNodeTypesStore } from '@/app/stores/nodeTypes.store';
|
||||
import { usePostHog } from '@/app/stores/posthog.store';
|
||||
import { useSettingsStore } from '@/app/stores/settings.store';
|
||||
import type { NodeIconSource } from '@/app/utils/nodeIcon';
|
||||
import { SampleTemplates } from '@/features/workflows/templates/utils/workflowSamples';
|
||||
|
|
@ -299,13 +297,12 @@ function applyNodeTags(element: INodeCreateElement): INodeCreateElement {
|
|||
text: i18n.baseText('generic.betaProper'),
|
||||
};
|
||||
} else if (
|
||||
usePostHog().getVariant(AI_GATEWAY_EXPERIMENT.name) === AI_GATEWAY_EXPERIMENT.variant &&
|
||||
useSettingsStore().isAiGatewayEnabled &&
|
||||
useAiGatewayStore().isNodeSupported(element.properties.name)
|
||||
) {
|
||||
element.properties.tag = {
|
||||
type: 'success',
|
||||
text: i18n.baseText('generic.freeCredits'),
|
||||
pill: true,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue