feat: trigger billing (#28335)

Signed-off-by: lyzno1 <yuanyouhuilyz@gmail.com>
Co-authored-by: lyzno1 <yuanyouhuilyz@gmail.com>
Co-authored-by: lyzno1 <92089059+lyzno1@users.noreply.github.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
Maries
2025-11-20 10:15:23 +08:00
committed by GitHub
parent c0b7ffd5d0
commit a1b735a4c0
61 changed files with 1475 additions and 465 deletions

View File

@ -49,6 +49,7 @@ import { fetchInstalledAppList } from '@/service/explore'
import { AppModeEnum } from '@/types/app'
import type { PublishWorkflowParams } from '@/types/workflow'
import { basePath } from '@/utils/var'
import UpgradeBtn from '@/app/components/billing/upgrade-btn'
const ACCESS_MODE_MAP: Record<AccessMode, { label: string, icon: React.ElementType }> = {
[AccessMode.ORGANIZATION]: {
@ -106,6 +107,7 @@ export type AppPublisherProps = {
workflowToolAvailable?: boolean
missingStartNode?: boolean
hasTriggerNode?: boolean // Whether workflow currently contains any trigger nodes (used to hide missing-start CTA when triggers exist).
startNodeLimitExceeded?: boolean
}
const PUBLISH_SHORTCUT = ['ctrl', '⇧', 'P']
@ -127,6 +129,7 @@ const AppPublisher = ({
workflowToolAvailable = true,
missingStartNode = false,
hasTriggerNode = false,
startNodeLimitExceeded = false,
}: AppPublisherProps) => {
const { t } = useTranslation()
@ -246,6 +249,13 @@ const AppPublisher = ({
const hasPublishedVersion = !!publishedAt
const workflowToolDisabled = !hasPublishedVersion || !workflowToolAvailable
const workflowToolMessage = workflowToolDisabled ? t('workflow.common.workflowAsToolDisabledHint') : undefined
const showStartNodeLimitHint = Boolean(startNodeLimitExceeded)
const upgradeHighlightStyle = useMemo(() => ({
background: 'linear-gradient(97deg, var(--components-input-border-active-prompt-1, rgba(11, 165, 236, 0.95)) -3.64%, var(--components-input-border-active-prompt-2, rgba(21, 90, 239, 0.95)) 45.14%)',
WebkitBackgroundClip: 'text',
backgroundClip: 'text',
WebkitTextFillColor: 'transparent',
}), [])
return (
<>
@ -304,29 +314,49 @@ const AppPublisher = ({
/>
)
: (
<Button
variant='primary'
className='mt-3 w-full'
onClick={() => handlePublish()}
disabled={publishDisabled || published}
>
{
published
? t('workflow.common.published')
: (
<div className='flex gap-1'>
<span>{t('workflow.common.publishUpdate')}</span>
<div className='flex gap-0.5'>
{PUBLISH_SHORTCUT.map(key => (
<span key={key} className='system-kbd h-4 w-4 rounded-[4px] bg-components-kbd-bg-white text-text-primary-on-surface'>
{getKeyboardKeyNameBySystem(key)}
</span>
))}
<>
<Button
variant='primary'
className='mt-3 w-full'
onClick={() => handlePublish()}
disabled={publishDisabled || published}
>
{
published
? t('workflow.common.published')
: (
<div className='flex gap-1'>
<span>{t('workflow.common.publishUpdate')}</span>
<div className='flex gap-0.5'>
{PUBLISH_SHORTCUT.map(key => (
<span key={key} className='system-kbd h-4 w-4 rounded-[4px] bg-components-kbd-bg-white text-text-primary-on-surface'>
{getKeyboardKeyNameBySystem(key)}
</span>
))}
</div>
</div>
</div>
)
}
</Button>
)
}
</Button>
{showStartNodeLimitHint && (
<div className='mt-3 flex flex-col items-stretch'>
<p
className='text-sm font-semibold leading-5 text-transparent'
style={upgradeHighlightStyle}
>
<span className='block'>{t('workflow.publishLimit.startNodeTitlePrefix')}</span>
<span className='block'>{t('workflow.publishLimit.startNodeTitleSuffix')}</span>
</p>
<p className='mt-1 text-xs leading-4 text-text-secondary'>
{t('workflow.publishLimit.startNodeDesc')}
</p>
<UpgradeBtn
isShort
className='mb-[12px] mt-[9px] h-[32px] w-[93px] self-start'
/>
</div>
)}
</>
)
}
</div>

View File

@ -90,4 +90,8 @@ export const defaultPlan = {
apiRateLimit: ALL_PLANS.sandbox.apiRateLimit,
triggerEvents: ALL_PLANS.sandbox.triggerEvents,
},
reset: {
apiRateLimit: null,
triggerEvents: null,
},
}

View File

@ -6,15 +6,16 @@ import { useRouter } from 'next/navigation'
import {
RiBook2Line,
RiFileEditLine,
RiFlashlightLine,
RiGraduationCapLine,
RiGroupLine,
RiSpeedLine,
} from '@remixicon/react'
import { Plan, SelfHostedPlan } from '../type'
import { NUM_INFINITE } from '../config'
import { getDaysUntilEndOfMonth } from '@/utils/time'
import VectorSpaceInfo from '../usage-info/vector-space-info'
import AppsInfo from '../usage-info/apps-info'
import UpgradeBtn from '../upgrade-btn'
import { ApiAggregate, TriggerAll } from '@/app/components/base/icons/src/vender/workflow'
import { useProviderContext } from '@/context/provider-context'
import { useAppContext } from '@/context/app-context'
import Button from '@/app/components/base/button'
@ -44,9 +45,20 @@ const PlanComp: FC<Props> = ({
const {
usage,
total,
reset,
} = plan
const perMonthUnit = ` ${t('billing.usagePage.perMonth')}`
const triggerEventUnit = plan.type === Plan.sandbox ? undefined : perMonthUnit
const triggerEventsResetInDays = type === Plan.professional && total.triggerEvents !== NUM_INFINITE
? reset.triggerEvents ?? undefined
: undefined
const apiRateLimitResetInDays = (() => {
if (total.apiRateLimit === NUM_INFINITE)
return undefined
if (typeof reset.apiRateLimit === 'number')
return reset.apiRateLimit
if (type === Plan.sandbox)
return getDaysUntilEndOfMonth()
return undefined
})()
const [showModal, setShowModal] = React.useState(false)
const { mutateAsync } = useEducationVerify()
@ -79,7 +91,6 @@ const PlanComp: FC<Props> = ({
<div className='grow'>
<div className='mb-1 flex items-center gap-1'>
<div className='system-md-semibold-uppercase text-text-primary'>{t(`billing.plans.${type}.name`)}</div>
<div className='system-2xs-medium-uppercase rounded-[5px] border border-divider-deep px-1 py-0.5 text-text-tertiary'>{t('billing.currentPlan')}</div>
</div>
<div className='system-xs-regular text-util-colors-gray-gray-600'>{t(`billing.plans.${type}.for`)}</div>
</div>
@ -124,18 +135,20 @@ const PlanComp: FC<Props> = ({
total={total.annotatedResponse}
/>
<UsageInfo
Icon={RiFlashlightLine}
Icon={TriggerAll}
name={t('billing.usagePage.triggerEvents')}
usage={usage.triggerEvents}
total={total.triggerEvents}
unit={triggerEventUnit}
tooltip={t('billing.plansCommon.triggerEvents.tooltip') as string}
resetInDays={triggerEventsResetInDays}
/>
<UsageInfo
Icon={RiSpeedLine}
Icon={ApiAggregate}
name={t('billing.plansCommon.apiRateLimit')}
usage={usage.apiRateLimit}
total={total.apiRateLimit}
unit={perMonthUnit}
tooltip={total.apiRateLimit === NUM_INFINITE ? undefined : t('billing.plansCommon.apiRateLimitTooltip') as string}
resetInDays={apiRateLimitResetInDays}
/>
</div>

View File

@ -46,16 +46,10 @@ const List = ({
label={t('billing.plansCommon.documentsRequestQuota', { count: planInfo.documentsRequestQuota })}
tooltip={t('billing.plansCommon.documentsRequestQuotaTooltip')}
/>
<Item
label={
planInfo.apiRateLimit === NUM_INFINITE ? `${t('billing.plansCommon.unlimitedApiRate')}`
: `${t('billing.plansCommon.apiRateLimitUnit', { count: planInfo.apiRateLimit })} ${t('billing.plansCommon.apiRateLimit')}`
}
tooltip={planInfo.apiRateLimit === NUM_INFINITE ? undefined : t('billing.plansCommon.apiRateLimitTooltip') as string}
/>
<Item
label={[t(`billing.plansCommon.priority.${planInfo.documentProcessingPriority}`), t('billing.plansCommon.documentProcessingPriority')].join('')}
/>
<Divider bgStyle='gradient' />
<Item
label={
planInfo.triggerEvents === NUM_INFINITE
@ -64,6 +58,14 @@ const List = ({
? t('billing.plansCommon.triggerEvents.sandbox', { count: planInfo.triggerEvents })
: t('billing.plansCommon.triggerEvents.professional', { count: planInfo.triggerEvents })
}
tooltip={t('billing.plansCommon.triggerEvents.tooltip') as string}
/>
<Item
label={
plan === Plan.sandbox
? t('billing.plansCommon.startNodes.limited', { count: 2 })
: t('billing.plansCommon.startNodes.unlimited')
}
/>
<Item
label={
@ -73,13 +75,7 @@ const List = ({
? t('billing.plansCommon.workflowExecution.faster')
: t('billing.plansCommon.workflowExecution.priority')
}
/>
<Item
label={
plan === Plan.sandbox
? t('billing.plansCommon.startNodes.limited', { count: 2 })
: t('billing.plansCommon.startNodes.unlimited')
}
tooltip={t('billing.plansCommon.workflowExecution.tooltip') as string}
/>
<Divider bgStyle='gradient' />
<Item
@ -89,6 +85,14 @@ const List = ({
<Item
label={t('billing.plansCommon.logsHistory', { days: planInfo.logHistory === NUM_INFINITE ? t('billing.plansCommon.unlimited') as string : `${planInfo.logHistory} ${t('billing.plansCommon.days')}` })}
/>
<Item
label={
planInfo.apiRateLimit === NUM_INFINITE
? t('billing.plansCommon.unlimitedApiRate')
: `${t('billing.plansCommon.apiRateLimitUnit', { count: planInfo.apiRateLimit })} ${t('billing.plansCommon.apiRateLimit')}/${t('billing.plansCommon.month')}`
}
tooltip={planInfo.apiRateLimit === NUM_INFINITE ? undefined : t('billing.plansCommon.apiRateLimitTooltip') as string}
/>
<Divider bgStyle='gradient' />
<Item
label={t('billing.plansCommon.modelProviders')}

View File

@ -0,0 +1,30 @@
.surface {
border: 0.5px solid var(--color-components-panel-border, rgba(16, 24, 40, 0.08));
background:
linear-gradient(109deg, var(--color-background-section, #f9fafb) 0%, var(--color-background-section-burn, #f2f4f7) 100%),
var(--color-components-panel-bg, #fff);
}
.heroOverlay {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='54' height='54' fill='none'%3E%3Crect x='1' y='1' width='48' height='48' rx='12' stroke='rgba(16, 24, 40, 0.3)' stroke-width='1' opacity='0.08'/%3E%3C/svg%3E");
background-size: 54px 54px;
background-position: 31px -23px;
background-repeat: repeat;
mask-image: linear-gradient(180deg, rgba(255, 255, 255, 1) 45%, rgba(255, 255, 255, 0) 75%);
-webkit-mask-image: linear-gradient(180deg, rgba(255, 255, 255, 1) 45%, rgba(255, 255, 255, 0) 75%);
}
.icon {
border: 0.5px solid transparent;
background:
linear-gradient(180deg, var(--color-components-avatar-bg-mask-stop-0, rgba(255, 255, 255, 0.12)) 0%, var(--color-components-avatar-bg-mask-stop-100, rgba(255, 255, 255, 0.08)) 100%),
var(--color-util-colors-blue-brand-blue-brand-500, #296dff);
box-shadow: 0 10px 20px color-mix(in srgb, var(--color-util-colors-blue-brand-blue-brand-500, #296dff) 35%, transparent);
}
.highlight {
background: linear-gradient(97deg, var(--color-components-input-border-active-prompt-1, rgba(11, 165, 236, 0.95)) -4%, var(--color-components-input-border-active-prompt-2, rgba(21, 90, 239, 0.95)) 45%);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
}

View File

@ -0,0 +1,97 @@
import type { Meta, StoryObj } from '@storybook/nextjs'
import React, { useEffect, useState } from 'react'
import i18next from 'i18next'
import { I18nextProvider } from 'react-i18next'
import TriggerEventsLimitModal from '.'
import { Plan } from '../type'
const i18n = i18next.createInstance()
i18n.init({
lng: 'en',
resources: {
en: {
translation: {
billing: {
triggerLimitModal: {
title: 'Upgrade to unlock unlimited triggers per workflow',
description: 'Youve reached the limit of 2 triggers per workflow for this plan. Upgrade to publish this workflow.',
dismiss: 'Dismiss',
upgrade: 'Upgrade',
usageTitle: 'TRIGGER EVENTS',
},
usagePage: {
triggerEvents: 'Trigger Events',
resetsIn: 'Resets in {{count, number}} days',
},
upgradeBtn: {
encourage: 'Upgrade Now',
encourageShort: 'Upgrade',
plain: 'View Plan',
},
},
},
},
},
})
const Template = (args: React.ComponentProps<typeof TriggerEventsLimitModal>) => {
const [visible, setVisible] = useState<boolean>(args.show ?? true)
useEffect(() => {
setVisible(args.show ?? true)
}, [args.show])
const handleHide = () => setVisible(false)
return (
<I18nextProvider i18n={i18n}>
<div className="flex flex-col gap-4">
<button
className="rounded-lg border border-divider-subtle px-4 py-2 text-sm text-text-secondary hover:border-divider-deep hover:text-text-primary"
onClick={() => setVisible(true)}
>
Open Modal
</button>
<TriggerEventsLimitModal
{...args}
show={visible}
onDismiss={handleHide}
onUpgrade={handleHide}
/>
</div>
</I18nextProvider>
)
}
const meta = {
title: 'Billing/TriggerEventsLimitModal',
component: TriggerEventsLimitModal,
parameters: {
layout: 'centered',
},
args: {
show: true,
usage: 120,
total: 120,
resetInDays: 5,
planType: Plan.professional,
},
} satisfies Meta<typeof TriggerEventsLimitModal>
export default meta
type Story = StoryObj<typeof meta>
export const Professional: Story = {
args: {
onDismiss: () => { /* noop */ },
onUpgrade: () => { /* noop */ },
},
render: args => <Template {...args} />,
}
export const Sandbox: Story = {
render: args => <Template {...args} />,
args: {
onDismiss: () => { /* noop */ },
onUpgrade: () => { /* noop */ },
resetInDays: undefined,
planType: Plan.sandbox,
},
}

View File

@ -0,0 +1,90 @@
'use client'
import type { FC } from 'react'
import React from 'react'
import { useTranslation } from 'react-i18next'
import Modal from '@/app/components/base/modal'
import Button from '@/app/components/base/button'
import { TriggerAll } from '@/app/components/base/icons/src/vender/workflow'
import UsageInfo from '@/app/components/billing/usage-info'
import UpgradeBtn from '@/app/components/billing/upgrade-btn'
import type { Plan } from '@/app/components/billing/type'
import styles from './index.module.css'
type Props = {
show: boolean
onDismiss: () => void
onUpgrade: () => void
usage: number
total: number
resetInDays?: number
planType: Plan
}
const TriggerEventsLimitModal: FC<Props> = ({
show,
onDismiss,
onUpgrade,
usage,
total,
resetInDays,
}) => {
const { t } = useTranslation()
return (
<Modal
isShow={show}
onClose={onDismiss}
closable={false}
clickOutsideNotClose
className={`${styles.surface} flex h-[360px] w-[580px] flex-col overflow-hidden rounded-2xl !p-0 shadow-xl`}
>
<div className='relative flex w-full flex-1 items-stretch justify-center'>
<div
aria-hidden
className={`${styles.heroOverlay} pointer-events-none absolute inset-0`}
/>
<div className='relative z-10 flex w-full flex-col items-start gap-4 px-8 pt-8'>
<div className={`${styles.icon} flex h-12 w-12 items-center justify-center rounded-[12px]`}>
<TriggerAll className='h-5 w-5 text-text-primary-on-surface' />
</div>
<div className='flex flex-col items-start gap-2'>
<div className={`${styles.highlight} title-lg-semi-bold`}>
{t('billing.triggerLimitModal.title')}
</div>
<div className='body-md-regular text-text-secondary'>
{t('billing.triggerLimitModal.description')}
</div>
</div>
<UsageInfo
className='mb-5 w-full rounded-[12px] bg-components-panel-on-panel-item-bg'
Icon={TriggerAll}
name={t('billing.triggerLimitModal.usageTitle')}
usage={usage}
total={total}
resetInDays={resetInDays}
hideIcon
/>
</div>
</div>
<div className='flex h-[76px] w-full items-center justify-end gap-2 px-8 pb-8 pt-5'>
<Button
className='h-8 w-[77px] min-w-[72px] !rounded-lg !border-[0.5px] px-3 py-2'
onClick={onDismiss}
>
{t('billing.triggerLimitModal.dismiss')}
</Button>
<UpgradeBtn
isShort
onClick={onUpgrade}
className='flex w-[93px] items-center justify-center !rounded-lg !px-2'
style={{ height: 32 }}
labelKey='billing.triggerLimitModal.upgrade'
loc='trigger-events-limit-modal'
/>
</div>
</Modal>
)
}
export default React.memo(TriggerEventsLimitModal)

View File

@ -55,6 +55,17 @@ export type SelfHostedPlanInfo = {
export type UsagePlanInfo = Pick<PlanInfo, 'buildApps' | 'teamMembers' | 'annotatedResponse' | 'documentsUploadQuota' | 'apiRateLimit' | 'triggerEvents'> & { vectorSpace: number }
export type UsageResetInfo = {
apiRateLimit?: number | null
triggerEvents?: number | null
}
export type BillingQuota = {
usage: number
limit: number
reset_date?: number | null
}
export enum DocumentProcessingPriority {
standard = 'standard',
priority = 'priority',
@ -88,14 +99,8 @@ export type CurrentPlanInfoBackend = {
size: number
limit: number // total. 0 means unlimited
}
api_rate_limit?: {
size: number
limit: number // total. 0 means unlimited
}
trigger_events?: {
size: number
limit: number // total. 0 means unlimited
}
api_rate_limit?: BillingQuota
trigger_event?: BillingQuota
docs_processing: DocumentProcessingPriority
can_replace_logo: boolean
model_load_balancing_enabled: boolean

View File

@ -1,5 +1,5 @@
'use client'
import type { FC } from 'react'
import type { CSSProperties, FC } from 'react'
import React from 'react'
import { useTranslation } from 'react-i18next'
import PremiumBadge from '../../base/premium-badge'
@ -9,19 +9,24 @@ import { useModalContext } from '@/context/modal-context'
type Props = {
className?: string
style?: CSSProperties
isFull?: boolean
size?: 'md' | 'lg'
isPlain?: boolean
isShort?: boolean
onClick?: () => void
loc?: string
labelKey?: string
}
const UpgradeBtn: FC<Props> = ({
className,
style,
isPlain = false,
isShort = false,
onClick: _onClick,
loc,
labelKey,
}) => {
const { t } = useTranslation()
const { setShowPricingModal } = useModalContext()
@ -40,10 +45,17 @@ const UpgradeBtn: FC<Props> = ({
}
}
const defaultBadgeLabel = t(`billing.upgradeBtn.${isShort ? 'encourageShort' : 'encourage'}`)
const label = labelKey ? t(labelKey) : defaultBadgeLabel
if (isPlain) {
return (
<Button onClick={onClick}>
{t('billing.upgradeBtn.plain')}
<Button
className={className}
style={style}
onClick={onClick}
>
{labelKey ? label : t('billing.upgradeBtn.plain')}
</Button>
)
}
@ -54,11 +66,13 @@ const UpgradeBtn: FC<Props> = ({
color='blue'
allowHover={true}
onClick={onClick}
className={className}
style={style}
>
<SparklesSoft className='flex h-3.5 w-3.5 items-center py-[1px] pl-[3px] text-components-premium-badge-indigo-text-stop-0' />
<div className='system-xs-medium'>
<span className='p-1'>
{t(`billing.upgradeBtn.${isShort ? 'encourageShort' : 'encourage'}`)}
{label}
</span>
</div>
</PremiumBadge>

View File

@ -16,10 +16,12 @@ type Props = {
total: number
unit?: string
unitPosition?: 'inline' | 'suffix'
resetHint?: string
resetInDays?: number
hideIcon?: boolean
}
const LOW = 50
const MIDDLE = 80
const WARNING_THRESHOLD = 80
const UsageInfo: FC<Props> = ({
className,
@ -30,28 +32,39 @@ const UsageInfo: FC<Props> = ({
total,
unit,
unitPosition = 'suffix',
resetHint,
resetInDays,
hideIcon = false,
}) => {
const { t } = useTranslation()
const percent = usage / total * 100
const color = (() => {
if (percent < LOW)
return 'bg-components-progress-bar-progress-solid'
if (percent < MIDDLE)
return 'bg-components-progress-warning-progress'
return 'bg-components-progress-error-progress'
})()
const color = percent >= 100
? 'bg-components-progress-error-progress'
: (percent >= WARNING_THRESHOLD ? 'bg-components-progress-warning-progress' : 'bg-components-progress-bar-progress-solid')
const isUnlimited = total === NUM_INFINITE
let totalDisplay: string | number = isUnlimited ? t('billing.plansCommon.unlimited') : total
if (!isUnlimited && unit && unitPosition === 'inline')
totalDisplay = `${total}${unit}`
const showUnit = !!unit && !isUnlimited && unitPosition === 'suffix'
const resetText = resetHint ?? (typeof resetInDays === 'number' ? t('billing.usagePage.resetsIn', { count: resetInDays }) : undefined)
const rightInfo = resetText
? (
<div className='system-xs-regular ml-auto flex-1 text-right text-text-tertiary'>
{resetText}
</div>
)
: (showUnit && (
<div className='system-xs-medium ml-auto text-text-tertiary'>
{unit}
</div>
))
return (
<div className={cn('flex flex-col gap-2 rounded-xl bg-components-panel-bg p-4', className)}>
<Icon className='h-4 w-4 text-text-tertiary' />
{!hideIcon && Icon && (
<Icon className='h-4 w-4 text-text-tertiary' />
)}
<div className='flex items-center gap-1'>
<div className='system-xs-medium text-text-tertiary'>{name}</div>
{tooltip && (
@ -70,11 +83,7 @@ const UsageInfo: FC<Props> = ({
<div className='system-md-regular text-text-quaternary'>/</div>
<div>{totalDisplay}</div>
</div>
{showUnit && (
<div className='system-xs-medium ml-auto text-text-tertiary'>
{unit}
</div>
)}
{rightInfo}
</div>
<ProgressBar
percent={percent}

View File

@ -1,4 +1,5 @@
import type { CurrentPlanInfoBackend } from '../type'
import dayjs from 'dayjs'
import type { BillingQuota, CurrentPlanInfoBackend } from '../type'
import { ALL_PLANS, NUM_INFINITE } from '@/app/components/billing/config'
const parseLimit = (limit: number) => {
@ -8,6 +9,40 @@ const parseLimit = (limit: number) => {
return limit
}
const normalizeResetDate = (resetDate?: number | null) => {
if (typeof resetDate !== 'number' || resetDate <= 0)
return null
if (resetDate >= 1e12)
return dayjs(resetDate)
if (resetDate >= 1e9)
return dayjs(resetDate * 1000)
const digits = resetDate.toString()
if (digits.length === 8) {
const year = digits.slice(0, 4)
const month = digits.slice(4, 6)
const day = digits.slice(6, 8)
const parsed = dayjs(`${year}-${month}-${day}`)
return parsed.isValid() ? parsed : null
}
return null
}
const getResetInDaysFromDate = (resetDate?: number | null) => {
const resetDay = normalizeResetDate(resetDate)
if (!resetDay)
return null
const diff = resetDay.startOf('day').diff(dayjs().startOf('day'), 'day')
if (Number.isNaN(diff) || diff < 0)
return null
return diff
}
export const parseCurrentPlan = (data: CurrentPlanInfoBackend) => {
const planType = data.billing.subscription.plan
const planPreset = ALL_PLANS[planType]
@ -15,6 +50,12 @@ export const parseCurrentPlan = (data: CurrentPlanInfoBackend) => {
const value = limit ?? fallback ?? 0
return parseLimit(value)
}
const getQuotaUsage = (quota?: BillingQuota) => quota?.usage ?? 0
const getQuotaResetInDays = (quota?: BillingQuota) => {
if (!quota)
return null
return getResetInDaysFromDate(quota.reset_date)
}
return {
type: planType,
@ -24,8 +65,8 @@ export const parseCurrentPlan = (data: CurrentPlanInfoBackend) => {
teamMembers: data.members.size,
annotatedResponse: data.annotation_quota_limit.size,
documentsUploadQuota: data.documents_upload_quota.size,
apiRateLimit: data.api_rate_limit?.size ?? 0,
triggerEvents: data.trigger_events?.size ?? 0,
apiRateLimit: getQuotaUsage(data.api_rate_limit),
triggerEvents: getQuotaUsage(data.trigger_event),
},
total: {
vectorSpace: parseLimit(data.vector_space.limit),
@ -34,7 +75,11 @@ export const parseCurrentPlan = (data: CurrentPlanInfoBackend) => {
annotatedResponse: parseLimit(data.annotation_quota_limit.limit),
documentsUploadQuota: parseLimit(data.documents_upload_quota.limit),
apiRateLimit: resolveLimit(data.api_rate_limit?.limit, planPreset?.apiRateLimit ?? NUM_INFINITE),
triggerEvents: resolveLimit(data.trigger_events?.limit, planPreset?.triggerEvents),
triggerEvents: resolveLimit(data.trigger_event?.limit, planPreset?.triggerEvents),
},
reset: {
apiRateLimit: getQuotaResetInDays(data.api_rate_limit),
triggerEvents: getQuotaResetInDays(data.trigger_event),
},
}
}

View File

@ -40,6 +40,8 @@ import useTheme from '@/hooks/use-theme'
import cn from '@/utils/classnames'
import { useIsChatMode } from '@/app/components/workflow/hooks'
import type { StartNodeType } from '@/app/components/workflow/nodes/start/types'
import { useProviderContext } from '@/context/provider-context'
import { Plan } from '@/app/components/billing/type'
const FeaturesTrigger = () => {
const { t } = useTranslation()
@ -50,6 +52,7 @@ const FeaturesTrigger = () => {
const appID = appDetail?.id
const setAppDetail = useAppStore(s => s.setAppDetail)
const { nodesReadOnly, getNodesReadOnly } = useNodesReadOnly()
const { plan, isFetchedPlan } = useProviderContext()
const publishedAt = useStore(s => s.publishedAt)
const draftUpdatedAt = useStore(s => s.draftUpdatedAt)
const toolPublished = useStore(s => s.toolPublished)
@ -95,6 +98,15 @@ const FeaturesTrigger = () => {
const hasTriggerNode = useMemo(() => (
nodes.some(node => isTriggerNode(node.data.type as BlockEnum))
), [nodes])
const startNodeLimitExceeded = useMemo(() => {
const entryCount = nodes.reduce((count, node) => {
const nodeType = node.data.type as BlockEnum
if (nodeType === BlockEnum.Start || isTriggerNode(nodeType))
return count + 1
return count
}, 0)
return isFetchedPlan && plan.type === Plan.sandbox && entryCount > 2
}, [nodes, plan.type, isFetchedPlan])
const resetWorkflowVersionHistory = useResetWorkflowVersionHistory()
const invalidateAppTriggers = useInvalidateAppTriggers()
@ -196,7 +208,8 @@ const FeaturesTrigger = () => {
crossAxisOffset: 4,
missingStartNode: !startNode,
hasTriggerNode,
publishDisabled: !hasWorkflowNodes,
startNodeLimitExceeded,
publishDisabled: !hasWorkflowNodes || startNodeLimitExceeded,
}}
/>
</>