feat: add InsufficientBudget error type and Pro badge i18n (#12886)

This commit is contained in:
YuTengjing
2026-03-10 23:43:24 +08:00
committed by GitHub
parent 8063378a1d
commit c2e9b45d4c
20 changed files with 159 additions and 28 deletions

View File

@@ -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!",

View File

@@ -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.",

View File

@@ -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.",

View File

@@ -355,6 +355,7 @@
"pin": "置顶",
"pinOff": "取消置顶",
"privacy": "隐私政策",
"pro": "Pro",
"productHunt.actionLabel": "支持我们",
"productHunt.description": "在 Product Hunt 上支持我们,您的支持对我们意义重大!",
"productHunt.title": "我们登上 Product Hunt 了!",

View File

@@ -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 后重试",

View File

@@ -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}} 计算积分已用尽。请立即升级以获取更多积分。",

View File

@@ -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"
}
}
}

View File

@@ -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: {

View File

@@ -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: {

View File

@@ -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

View File

@@ -0,0 +1,8 @@
export interface BusinessModelListGuard {
isModelRestricted?: (modelId: string, providerId: string) => boolean;
onRestrictedModelClick?: () => void;
}
export const useBusinessModelListGuard = (): BusinessModelListGuard => {
return {};
};

View File

@@ -237,6 +237,7 @@ export const ModelInfoTags = memo<ModelInfoTagsProps>(
interface ModelItemRenderProps extends ChatModelCard, Partial<Omit<FlexboxProps, 'id' | 'title'>> {
abilities?: ModelAbilities;
newBadgeLabel?: string;
proBadgeLabel?: string;
showInfoTag?: boolean;
}
@@ -249,6 +250,7 @@ export const ModelItemRender = memo<ModelItemRenderProps>(
functionCall,
imageOutput,
newBadgeLabel,
proBadgeLabel,
video,
vision,
id,
@@ -294,6 +296,11 @@ export const ModelItemRender = memo<ModelItemRenderProps>(
) : (
<NewModelBadgeI18n releasedAt={releasedAt} />
)}
{proBadgeLabel && (
<Tag color="gold" size="small">
{proBadgeLabel}
</Tag>
)}
</Flexbox>
{showInfoTag && (
<ModelInfoTags

View File

@@ -28,14 +28,26 @@ import { SingleProviderModelItem } from './SingleProviderModelItem';
interface ListItemRendererProps {
activeKey: string;
isModelRestricted?: (modelId: string, providerId: string) => boolean;
item: ListItem;
newLabel: string;
onClose: () => void;
onModelChange: (modelId: string, providerId: string) => Promise<void>;
onRestrictedModelClick?: () => void;
proLabel?: string;
}
export const ListItemRenderer = memo<ListItemRendererProps>(
({ 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<ListItemRendererProps>(
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 (
<Flexbox style={{ marginBlock: 1, marginInline: 4 }}>
@@ -122,6 +135,11 @@ export const ListItemRenderer = memo<ListItemRendererProps>(
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<ListItemRendererProps>(
{...item.model.abilities}
showInfoTag
newBadgeLabel={newLabel}
proBadgeLabel={restricted ? proLabel : undefined}
/>
</DropdownMenuSubmenuTrigger>
<DropdownMenuPortal>
@@ -150,6 +169,7 @@ export const ListItemRenderer = memo<ListItemRendererProps>(
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 (
<Flexbox style={{ marginBlock: 1, marginInline: 4 }}>
@@ -158,12 +178,21 @@ export const ListItemRenderer = memo<ListItemRendererProps>(
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();
}}
>
<SingleProviderModelItem data={item.data} newLabel={newLabel} />
<SingleProviderModelItem
data={item.data}
newLabel={newLabel}
proBadgeLabel={restricted ? proLabel : undefined}
/>
</DropdownMenuSubmenuTrigger>
<DropdownMenuPortal>
<DropdownMenuPositioner anchor={null} placement="right" sideOffset={16}>
@@ -183,9 +212,12 @@ export const ListItemRenderer = memo<ListItemRendererProps>(
<MultipleProvidersModelItem
activeKey={activeKey}
data={item.data}
isModelRestricted={isModelRestricted}
newLabel={newLabel}
proLabel={proLabel}
onClose={onClose}
onModelChange={onModelChange}
onRestrictedModelClick={onRestrictedModelClick}
/>
</Flexbox>
);

View File

@@ -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<void>;
onRestrictedModelClick?: () => void;
proLabel?: string;
}
export const MultipleProvidersModelItem = memo<MultipleProvidersModelItemProps>(
({ 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<MultipleProvidersModelItemProps>(
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 (
<DropdownMenuSubmenuRoot open={submenuOpen} onOpenChange={setSubmenuOpen}>
<DropdownMenuSubmenuRoot
open={submenuOpen}
onOpenChange={(open) => {
if (allRestricted && open) return;
setSubmenuOpen(open);
}}
>
<DropdownMenuSubmenuTrigger
className={cx(menuSharedStyles.item, isActive && styles.menuItemActive)}
style={{ paddingBlock: 8, paddingInline: 8 }}
onClick={() => {
if (allRestricted) {
onRestrictedModelClick?.();
onClose();
}
}}
>
<ModelItemRender
{...data.model}
{...data.model.abilities}
newBadgeLabel={newLabel}
proBadgeLabel={allRestricted ? proLabel : undefined}
showInfoTag={true}
/>
</DropdownMenuSubmenuTrigger>
@@ -71,11 +103,17 @@ export const MultipleProvidersModelItem = memo<MultipleProvidersModelItemProps>(
{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 (
<DropdownMenuItem
key={key}
onClick={async () => {
if (providerRestricted) {
onRestrictedModelClick?.();
onClose();
return;
}
await onModelChange(data.model.id, p.id);
onClose();
}}
@@ -84,14 +122,23 @@ export const MultipleProvidersModelItem = memo<MultipleProvidersModelItemProps>(
{isProviderActive ? <Check size={16} /> : null}
</DropdownMenuItemIcon>
<DropdownMenuItemLabel>
<ProviderItemRender
logo={p.logo}
name={p.name}
provider={p.id}
size={20}
source={p.source}
type={'avatar'}
/>
<Flexbox horizontal align="center" gap={8}>
<Flexbox horizontal align="center" style={{ flex: 'none' }}>
<ProviderItemRender
logo={p.logo}
name={p.name}
provider={p.id}
size={20}
source={p.source}
type={'avatar'}
/>
</Flexbox>
{providerRestricted && proLabel && (
<Tag color="gold" size="small">
{proLabel}
</Tag>
)}
</Flexbox>
</DropdownMenuItemLabel>
<DropdownMenuItemExtra>
<ActionIcon

View File

@@ -7,17 +7,21 @@ import { type ModelWithProviders } from '../../types';
interface SingleProviderModelItemProps {
data: ModelWithProviders;
newLabel: string;
proBadgeLabel?: string;
}
export const SingleProviderModelItem = memo<SingleProviderModelItemProps>(({ data, newLabel }) => {
return (
<ModelItemRender
{...data.model}
{...data.model.abilities}
newBadgeLabel={newLabel}
showInfoTag={true}
/>
);
});
export const SingleProviderModelItem = memo<SingleProviderModelItemProps>(
({ data, newLabel, proBadgeLabel }) => {
return (
<ModelItemRender
{...data.model}
{...data.model.abilities}
newBadgeLabel={newLabel}
proBadgeLabel={proBadgeLabel}
showInfoTag={true}
/>
);
},
);
SingleProviderModelItem.displayName = 'SingleProviderModelItem';

View File

@@ -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<ListProps> = ({
}) => {
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<ListProps> = ({
const renderItem = (key?: string) => (
<ListItemRenderer
activeKey={activeKey}
isModelRestricted={isModelRestricted}
item={item}
key={key}
newLabel={newLabel}
proLabel={proLabel}
onClose={handleClose}
onModelChange={handleModelChange}
onRestrictedModelClick={onRestrictedModelClick}
/>
);

View File

@@ -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',

View File

@@ -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':

View File

@@ -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':

View File

@@ -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;
}