From c2e9b45d4c627c2246144de2fff637f9f7d55069 Mon Sep 17 00:00:00 2001 From: YuTengjing Date: Tue, 10 Mar 2026 23:43:24 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat:=20add=20InsufficientBudget=20?= =?UTF-8?q?error=20type=20and=20Pro=20badge=20i18n=20(#12886)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- locales/en-US/common.json | 1 + locales/en-US/error.json | 1 + locales/en-US/subscription.json | 3 + locales/zh-CN/common.json | 1 + locales/zh-CN/error.json | 1 + locales/zh-CN/subscription.json | 3 + package.json | 10 ++- packages/model-bank/src/aiModels/aihubmix.ts | 4 +- packages/model-bank/src/aiModels/openai.ts | 4 +- packages/types/src/fetch.ts | 1 + .../client/hooks/useBusinessModelListGuard.ts | 8 +++ src/components/ModelSelect/index.tsx | 7 ++ .../components/List/ListItemRenderer.tsx | 36 +++++++++- .../List/MultipleProvidersModelItem.tsx | 67 ++++++++++++++++--- .../List/SingleProviderModelItem.tsx | 24 ++++--- .../components/List/index.tsx | 6 ++ src/locales/default/common.ts | 1 + src/locales/default/error.ts | 2 + src/locales/default/subscription.ts | 4 ++ src/utils/errorResponse.ts | 3 +- 20 files changed, 159 insertions(+), 28 deletions(-) create mode 100644 src/business/client/hooks/useBusinessModelListGuard.ts diff --git a/locales/en-US/common.json b/locales/en-US/common.json index b8c0d42c6d..0c78bf02df 100644 --- a/locales/en-US/common.json +++ b/locales/en-US/common.json @@ -355,6 +355,7 @@ "pin": "Pin", "pinOff": "Unpin", "privacy": "Privacy Policy", + "pro": "Pro", "productHunt.actionLabel": "Support us", "productHunt.description": "Support us on Product Hunt. Your support means a lot to us!", "productHunt.title": "We're on Product Hunt!", diff --git a/locales/en-US/error.json b/locales/en-US/error.json index 1758eb2488..2a693015ed 100644 --- a/locales/en-US/error.json +++ b/locales/en-US/error.json @@ -78,6 +78,7 @@ "response.GoogleAIBlockReason.SAFETY": "Your content was blocked for safety policy reasons. Please adjust your request to avoid potentially harmful or inappropriate content.", "response.GoogleAIBlockReason.SPII": "Your content may contain sensitive personally identifiable information (PII). To protect privacy, please remove any sensitive details and try again.", "response.GoogleAIBlockReason.default": "Content blocked: {{blockReason}}. Please adjust your request and try again.", + "response.InsufficientBudgetForModel": "Your remaining credits are insufficient for this model. Please top up credits, upgrade your plan, or try a less expensive model.", "response.InsufficientQuota": "Sorry, the quota for this key has been reached. Please check if your account balance is sufficient or try again after increasing the key's quota.", "response.InvalidAccessCode": "Invalid access code or empty. Please enter the correct access code or add a custom API Key.", "response.InvalidBedrockCredentials": "Bedrock authentication failed. Please check the AccessKeyId/SecretAccessKey and retry.", diff --git a/locales/en-US/subscription.json b/locales/en-US/subscription.json index 5cf5764d11..58787f034e 100644 --- a/locales/en-US/subscription.json +++ b/locales/en-US/subscription.json @@ -113,6 +113,9 @@ "limitation.image.topupSuccess.action": "Continue Generating", "limitation.image.topupSuccess.desc": "Your top-up credits are now active. Enjoy AI image generation. Your current plan includes:", "limitation.image.topupSuccess.title": "Top-up Successful", + "limitation.insufficientBudget.desc": "Your remaining credits are not enough for the estimated cost of this model. Please top up credits or switch to a less expensive model.", + "limitation.insufficientBudget.retry": "Retry", + "limitation.insufficientBudget.title": "Insufficient Credits for This Model", "limitation.limited.action": "Upgrade Now", "limitation.limited.advanceFeature": "Upgrade to enjoy premium features:", "limitation.limited.desc": "Your {{plan}} computing credits have been exhausted. Upgrade now to get more credits.", diff --git a/locales/zh-CN/common.json b/locales/zh-CN/common.json index 9f3dd62f67..0fbe1d6d70 100644 --- a/locales/zh-CN/common.json +++ b/locales/zh-CN/common.json @@ -355,6 +355,7 @@ "pin": "置顶", "pinOff": "取消置顶", "privacy": "隐私政策", + "pro": "Pro", "productHunt.actionLabel": "支持我们", "productHunt.description": "在 Product Hunt 上支持我们,您的支持对我们意义重大!", "productHunt.title": "我们登上 Product Hunt 了!", diff --git a/locales/zh-CN/error.json b/locales/zh-CN/error.json index 6f5c46656d..00e4892ab8 100644 --- a/locales/zh-CN/error.json +++ b/locales/zh-CN/error.json @@ -78,6 +78,7 @@ "response.GoogleAIBlockReason.SAFETY": "内容触发安全策略。请移除可能有害或不当的部分后重试", "response.GoogleAIBlockReason.SPII": "内容可能包含敏感个人身份信息。为保护隐私,请移除后重试", "response.GoogleAIBlockReason.default": "内容被阻止:{{blockReason}}。请调整后重试", + "response.InsufficientBudgetForModel": "剩余额度不足以使用此模型。请充值额度、升级订阅计划,或尝试使用费用更低的模型。", "response.InsufficientQuota": "配额已用尽。请检查余额/配额设置,或更换可用的 API Key 后重试", "response.InvalidAccessCode": "访问密码为空或不正确。请重新输入,或改用自定义 API Key", "response.InvalidBedrockCredentials": "Bedrock 鉴权失败。请检查 AccessKeyId/SecretAccessKey 后重试", diff --git a/locales/zh-CN/subscription.json b/locales/zh-CN/subscription.json index 3663538be4..a7f6a6d3ce 100644 --- a/locales/zh-CN/subscription.json +++ b/locales/zh-CN/subscription.json @@ -113,6 +113,9 @@ "limitation.image.topupSuccess.action": "继续生成", "limitation.image.topupSuccess.desc": "您的充值积分已激活,畅享 AI 图像生成。当前套餐包含:", "limitation.image.topupSuccess.title": "充值成功", + "limitation.insufficientBudget.desc": "剩余额度不足以支付此模型的预估费用。请充值积分或切换到费用更低的模型。", + "limitation.insufficientBudget.retry": "重试", + "limitation.insufficientBudget.title": "额度不足以使用此模型", "limitation.limited.action": "立即升级", "limitation.limited.advanceFeature": "升级以解锁高级功能:", "limitation.limited.desc": "您的 {{plan}} 计算积分已用尽。请立即升级以获取更多积分。", diff --git a/package.json b/package.json index 88e6d26686..c9a3303c82 100644 --- a/package.json +++ b/package.json @@ -152,6 +152,11 @@ ] }, "overrides": { + "@types/react": "19.2.13", + "better-auth": "1.4.6", + "better-call": "1.1.8", + "drizzle-orm": "^0.45.1", + "fast-xml-parser": "5.4.2", "pdfjs-dist": "5.4.530", "stylelint-config-clean-order": "7.0.0" }, @@ -510,7 +515,10 @@ "@types/react": "19.2.13", "better-auth": "1.4.6", "better-call": "1.1.8", - "drizzle-orm": "^0.45.1" + "drizzle-orm": "^0.45.1", + "fast-xml-parser": "5.4.2", + "pdfjs-dist": "5.4.530", + "stylelint-config-clean-order": "7.0.0" } } } diff --git a/packages/model-bank/src/aiModels/aihubmix.ts b/packages/model-bank/src/aiModels/aihubmix.ts index 38b768a09a..d7eb1a940d 100644 --- a/packages/model-bank/src/aiModels/aihubmix.ts +++ b/packages/model-bank/src/aiModels/aihubmix.ts @@ -72,8 +72,8 @@ const aihubmixModels: AIChatModelCard[] = [ }, contextWindowTokens: 1_050_000, description: - 'GPT-5.4 pro uses more compute to think harder and provide consistently better answers, available in the Responses API only.', - displayName: 'GPT-5.4 pro', + 'GPT-5.4 Pro uses more compute to think harder and provide consistently better answers, available in the Responses API only.', + displayName: 'GPT-5.4 Pro', id: 'gpt-5.4-pro', maxOutput: 128_000, pricing: { diff --git a/packages/model-bank/src/aiModels/openai.ts b/packages/model-bank/src/aiModels/openai.ts index 0c3ffaf708..b895a8b9af 100644 --- a/packages/model-bank/src/aiModels/openai.ts +++ b/packages/model-bank/src/aiModels/openai.ts @@ -89,8 +89,8 @@ export const openaiChatModels: AIChatModelCard[] = [ }, contextWindowTokens: 1_050_000, description: - 'GPT-5.4 pro uses more compute to think harder and provide consistently better answers, available in the Responses API only.', - displayName: 'GPT-5.4 pro', + 'GPT-5.4 Pro uses more compute to think harder and provide consistently better answers, available in the Responses API only.', + displayName: 'GPT-5.4 Pro', id: 'gpt-5.4-pro', maxOutput: 128_000, pricing: { diff --git a/packages/types/src/fetch.ts b/packages/types/src/fetch.ts index 241d7c1b37..a0a29fdd99 100644 --- a/packages/types/src/fetch.ts +++ b/packages/types/src/fetch.ts @@ -6,6 +6,7 @@ export const ChatErrorType = { InvalidAccessCode: 'InvalidAccessCode', // is in valid password FreePlanLimit: 'FreePlanLimit', // Free plan usage limit SubscriptionPlanLimit: 'SubscriptionPlanLimit', // Subscription user limit exceeded + InsufficientBudgetForModel: 'InsufficientBudgetForModel', // Has credits but not enough for estimated model cost SubscriptionKeyMismatch: 'SubscriptionKeyMismatch', // Subscription key mismatch SupervisorDecisionFailed: 'SupervisorDecisionFailed', // Supervisor decision failed diff --git a/src/business/client/hooks/useBusinessModelListGuard.ts b/src/business/client/hooks/useBusinessModelListGuard.ts new file mode 100644 index 0000000000..b4b3e50956 --- /dev/null +++ b/src/business/client/hooks/useBusinessModelListGuard.ts @@ -0,0 +1,8 @@ +export interface BusinessModelListGuard { + isModelRestricted?: (modelId: string, providerId: string) => boolean; + onRestrictedModelClick?: () => void; +} + +export const useBusinessModelListGuard = (): BusinessModelListGuard => { + return {}; +}; diff --git a/src/components/ModelSelect/index.tsx b/src/components/ModelSelect/index.tsx index 65c381f405..1972ff7d73 100644 --- a/src/components/ModelSelect/index.tsx +++ b/src/components/ModelSelect/index.tsx @@ -237,6 +237,7 @@ export const ModelInfoTags = memo( interface ModelItemRenderProps extends ChatModelCard, Partial> { abilities?: ModelAbilities; newBadgeLabel?: string; + proBadgeLabel?: string; showInfoTag?: boolean; } @@ -249,6 +250,7 @@ export const ModelItemRender = memo( functionCall, imageOutput, newBadgeLabel, + proBadgeLabel, video, vision, id, @@ -294,6 +296,11 @@ export const ModelItemRender = memo( ) : ( )} + {proBadgeLabel && ( + + {proBadgeLabel} + + )} {showInfoTag && ( boolean; item: ListItem; newLabel: string; onClose: () => void; onModelChange: (modelId: string, providerId: string) => Promise; + onRestrictedModelClick?: () => void; + proLabel?: string; } export const ListItemRenderer = memo( - ({ activeKey, item, newLabel, onModelChange, onClose }) => { + ({ + activeKey, + isModelRestricted, + item, + newLabel, + onModelChange, + onClose, + onRestrictedModelClick, + proLabel, + }) => { const { t } = useTranslation('components'); const navigate = useNavigate(); const [detailOpen, setDetailOpen] = useState(false); @@ -114,6 +126,7 @@ export const ListItemRenderer = memo( case 'provider-model-item': { const key = menuKey(item.provider.id, item.model.id); const isActive = key === activeKey; + const restricted = isModelRestricted?.(item.model.id, item.provider.id); return ( @@ -122,6 +135,11 @@ export const ListItemRenderer = memo( className={cx(menuSharedStyles.item, isActive && styles.menuItemActive)} style={{ paddingBlock: 8, paddingInline: 8 }} onClick={async () => { + if (restricted) { + onRestrictedModelClick?.(); + onClose(); + return; + } setDetailOpen(false); onModelChange(item.model.id, item.provider.id); onClose(); @@ -132,6 +150,7 @@ export const ListItemRenderer = memo( {...item.model.abilities} showInfoTag newBadgeLabel={newLabel} + proBadgeLabel={restricted ? proLabel : undefined} /> @@ -150,6 +169,7 @@ export const ListItemRenderer = memo( const singleProvider = item.data.providers[0]; const key = menuKey(singleProvider.id, item.data.model.id); const isActive = key === activeKey; + const restricted = isModelRestricted?.(item.data.model.id, singleProvider.id); return ( @@ -158,12 +178,21 @@ export const ListItemRenderer = memo( className={cx(menuSharedStyles.item, isActive && styles.menuItemActive)} style={{ paddingBlock: 8, paddingInline: 8 }} onClick={async () => { + if (restricted) { + onRestrictedModelClick?.(); + onClose(); + return; + } setDetailOpen(false); onModelChange(item.data.model.id, singleProvider.id); onClose(); }} > - + @@ -183,9 +212,12 @@ export const ListItemRenderer = memo( ); diff --git a/src/features/ModelSwitchPanel/components/List/MultipleProvidersModelItem.tsx b/src/features/ModelSwitchPanel/components/List/MultipleProvidersModelItem.tsx index c0893eb8a9..b823cf2854 100644 --- a/src/features/ModelSwitchPanel/components/List/MultipleProvidersModelItem.tsx +++ b/src/features/ModelSwitchPanel/components/List/MultipleProvidersModelItem.tsx @@ -11,7 +11,9 @@ import { DropdownMenuPositioner, DropdownMenuSubmenuRoot, DropdownMenuSubmenuTrigger, + Flexbox, menuSharedStyles, + Tag, } from '@lobehub/ui'; import { cx } from 'antd-style'; import { Check, LucideBolt } from 'lucide-react'; @@ -30,13 +32,25 @@ import ModelDetailPanel from '../ModelDetailPanel'; interface MultipleProvidersModelItemProps { activeKey: string; data: ModelWithProviders; + isModelRestricted?: (modelId: string, providerId: string) => boolean; newLabel: string; onClose: () => void; onModelChange: (modelId: string, providerId: string) => Promise; + onRestrictedModelClick?: () => void; + proLabel?: string; } export const MultipleProvidersModelItem = memo( - ({ activeKey, data, newLabel, onModelChange, onClose }) => { + ({ + activeKey, + data, + isModelRestricted, + newLabel, + onModelChange, + onClose, + onRestrictedModelClick, + proLabel, + }) => { const { t } = useTranslation('components'); const navigate = useNavigate(); const [submenuOpen, setSubmenuOpen] = useState(false); @@ -44,16 +58,34 @@ export const MultipleProvidersModelItem = memo( const activeProvider = data.providers.find((p) => menuKey(p.id, data.model.id) === activeKey); const isActive = !!activeProvider; + const allRestricted = + isModelRestricted && + data.providers.length > 0 && + data.providers.every((p) => isModelRestricted(data.model.id, p.id)); + return ( - + { + if (allRestricted && open) return; + setSubmenuOpen(open); + }} + > { + if (allRestricted) { + onRestrictedModelClick?.(); + onClose(); + } + }} > @@ -71,11 +103,17 @@ export const MultipleProvidersModelItem = memo( {data.providers.map((p) => { const key = menuKey(p.id, data.model.id); const isProviderActive = activeKey === key; + const providerRestricted = isModelRestricted?.(data.model.id, p.id); return ( { + if (providerRestricted) { + onRestrictedModelClick?.(); + onClose(); + return; + } await onModelChange(data.model.id, p.id); onClose(); }} @@ -84,14 +122,23 @@ export const MultipleProvidersModelItem = memo( {isProviderActive ? : null} - + + + + + {providerRestricted && proLabel && ( + + {proLabel} + + )} + (({ data, newLabel }) => { - return ( - - ); -}); +export const SingleProviderModelItem = memo( + ({ data, newLabel, proBadgeLabel }) => { + return ( + + ); + }, +); SingleProviderModelItem.displayName = 'SingleProviderModelItem'; diff --git a/src/features/ModelSwitchPanel/components/List/index.tsx b/src/features/ModelSwitchPanel/components/List/index.tsx index a175f10492..5ea3487eaf 100644 --- a/src/features/ModelSwitchPanel/components/List/index.tsx +++ b/src/features/ModelSwitchPanel/components/List/index.tsx @@ -3,6 +3,7 @@ import { type FC } from 'react'; import { useCallback, useLayoutEffect, useMemo, useRef } from 'react'; import { useTranslation } from 'react-i18next'; +import { useBusinessModelListGuard } from '@/business/client/hooks/useBusinessModelListGuard'; import { useEnabledChatModels } from '@/hooks/useEnabledChatModels'; import { FOOTER_HEIGHT, ITEM_HEIGHT, MAX_PANEL_HEIGHT, TOOLBAR_HEIGHT } from '../../const'; @@ -33,6 +34,8 @@ export const List: FC = ({ }) => { const { t: tCommon } = useTranslation('common'); const newLabel = tCommon('new'); + const { isModelRestricted, onRestrictedModelClick } = useBusinessModelListGuard(); + const proLabel = isModelRestricted ? tCommon('pro') : undefined; const enabledList = useEnabledChatModels(); const { model, provider } = useModelAndProvider(modelProp, providerProp); @@ -98,11 +101,14 @@ export const List: FC = ({ const renderItem = (key?: string) => ( ); diff --git a/src/locales/default/common.ts b/src/locales/default/common.ts index cd0fdb99e4..f0696951fa 100644 --- a/src/locales/default/common.ts +++ b/src/locales/default/common.ts @@ -419,6 +419,7 @@ export default { 'navPanel.searchAgent': 'Search Agent...', 'navPanel.searchResultEmpty': 'No search results found', 'new': 'New', + 'pro': 'Pro', 'noContent': 'No content', 'oauth': 'SSO Login', 'officialSite': 'Official Website', diff --git a/src/locales/default/error.ts b/src/locales/default/error.ts index 93b45ffa19..b6bb2a71d5 100644 --- a/src/locales/default/error.ts +++ b/src/locales/default/error.ts @@ -112,6 +112,8 @@ export default { 'The model service is currently under heavy load. Please try again later.', 'response.FreePlanLimit': 'You are currently a free user and cannot use this feature. Please upgrade to a paid plan to continue using it.', + 'response.InsufficientBudgetForModel': + 'Your remaining credits are insufficient for this model. Please top up credits, upgrade your plan, or try a less expensive model.', 'response.GoogleAIBlockReason.BLOCKLIST': 'Your content contains prohibited terms. Please review and modify your input, then try again.', 'response.GoogleAIBlockReason.IMAGE_SAFETY': diff --git a/src/locales/default/subscription.ts b/src/locales/default/subscription.ts index f69b18e334..4692a370f7 100644 --- a/src/locales/default/subscription.ts +++ b/src/locales/default/subscription.ts @@ -113,6 +113,10 @@ export default { 'limitation.expired.desc': 'Your {{plan}} computing credits expired on {{expiredAt}}. Upgrade your plan now to get computing credits.', 'limitation.expired.title': 'Computing Credits Expired', + 'limitation.insufficientBudget.desc': + 'Your remaining credits are not enough for the estimated cost of this model. Please top up credits or switch to a less expensive model.', + 'limitation.insufficientBudget.retry': 'Retry', + 'limitation.insufficientBudget.title': 'Insufficient Credits for This Model', 'limitation.hobby.action': 'Configured, continue chatting', 'limitation.hobby.configAPI': 'Configure API', 'limitation.hobby.desc': diff --git a/src/utils/errorResponse.ts b/src/utils/errorResponse.ts index 81b2e641ae..8ee7e3e597 100644 --- a/src/utils/errorResponse.ts +++ b/src/utils/errorResponse.ts @@ -17,7 +17,8 @@ const getStatus = (errorType: ILobeAgentRuntimeErrorType | ErrorType) => { switch (errorType) { case ChatErrorType.SubscriptionPlanLimit: - case ChatErrorType.FreePlanLimit: { + case ChatErrorType.FreePlanLimit: + case ChatErrorType.InsufficientBudgetForModel: { return 403; }