From b8339abc767191c15c34cf093bfc7bd591ed3b95 Mon Sep 17 00:00:00 2001 From: YuTengjing Date: Wed, 10 Jun 2026 18:19:25 +0800 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20fix:=20show=20plan=20limit=20upg?= =?UTF-8?q?rade=20UI=20on=20desktop=20builds=20(#15628)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- locales/en-US/error.json | 2 + locales/zh-CN/error.json | 2 + packages/business/const/src/branding.ts | 2 +- packages/locales/src/default/error.ts | 2 + .../Error/DeprecatedModelError.tsx | 26 +++ .../Error/ExceededContextWindowError.tsx | 23 +-- .../Error/PlanLimitCard/budget.test.ts | 54 +++++ .../Error/PlanLimitCard/budget.ts | 44 ++++ .../Error/PlanLimitCard/index.tsx | 190 ++++++++++++++++++ .../Conversation/Error/QuotaLimitError.tsx | 39 ++++ .../Conversation/Error/TraceIdError.tsx | 88 ++++++++ .../Conversation/Error/index.test.tsx | 50 ++++- src/features/Conversation/Error/index.tsx | 73 ++++++- .../Error/useRetryParentMessage.test.ts | 71 +++++++ .../Error/useRetryParentMessage.ts | 35 ++++ 15 files changed, 681 insertions(+), 20 deletions(-) create mode 100644 src/features/Conversation/Error/DeprecatedModelError.tsx create mode 100644 src/features/Conversation/Error/PlanLimitCard/budget.test.ts create mode 100644 src/features/Conversation/Error/PlanLimitCard/budget.ts create mode 100644 src/features/Conversation/Error/PlanLimitCard/index.tsx create mode 100644 src/features/Conversation/Error/QuotaLimitError.tsx create mode 100644 src/features/Conversation/Error/TraceIdError.tsx create mode 100644 src/features/Conversation/Error/useRetryParentMessage.test.ts create mode 100644 src/features/Conversation/Error/useRetryParentMessage.ts diff --git a/locales/en-US/error.json b/locales/en-US/error.json index 9129d6344f..6a062e4b74 100644 --- a/locales/en-US/error.json +++ b/locales/en-US/error.json @@ -109,9 +109,11 @@ "transfer.targetNoWriteAccess": "You need Member or Owner access to move resources into the target workspace.", "tts.responseError": "Service request failed, please check the configuration or try again", "unknownError.copyTraceId": "Trace ID Copied", + "unknownError.copyTraceIdTooltip": "Click to copy", "unknownError.desc": "An unexpected error occurred. You can retry or report on", "unknownError.retry": "Retry", "unknownError.title": "Oops, the request took a nap", + "unknownError.traceIdLabel": "Trace ID:", "unlock.addProxyUrl": "Add OpenAI proxy URL (optional)", "unlock.apiKey.description": "Enter your {{name}} API Key to start the session", "unlock.apiKey.imageGenerationDescription": "Enter your {{name}} API Key to start generating", diff --git a/locales/zh-CN/error.json b/locales/zh-CN/error.json index da9c44036a..4cfba508ae 100644 --- a/locales/zh-CN/error.json +++ b/locales/zh-CN/error.json @@ -109,9 +109,11 @@ "transfer.targetNoWriteAccess": "您需要成员或所有者权限才能将资源移动到目标工作区。", "tts.responseError": "请求失败。请检查配置后重试", "unknownError.copyTraceId": "Trace ID 已复制", + "unknownError.copyTraceIdTooltip": "点击复制", "unknownError.desc": "遇到了意外错误,请重试或反馈至", "unknownError.retry": "重试", "unknownError.title": "糟糕,请求打了个盹", + "unknownError.traceIdLabel": "追踪 ID:", "unlock.addProxyUrl": "添加 OpenAI 代理地址(可选)", "unlock.apiKey.description": "输入你的 {{name}} API Key,即可开始会话", "unlock.apiKey.imageGenerationDescription": "输入你的 {{name}} API Key,即可开始生成", diff --git a/packages/business/const/src/branding.ts b/packages/business/const/src/branding.ts index ea970e1cd1..8624df54e8 100644 --- a/packages/business/const/src/branding.ts +++ b/packages/business/const/src/branding.ts @@ -12,7 +12,7 @@ export const ORG_NAME = 'LobeHub'; export const BRANDING_URL = { help: undefined, privacy: undefined, - subscription: undefined, + subscription: 'https://app.lobehub.com/settings/plans', support: undefined, terms: undefined, }; diff --git a/packages/locales/src/default/error.ts b/packages/locales/src/default/error.ts index 81ec737554..131ac1744b 100644 --- a/packages/locales/src/default/error.ts +++ b/packages/locales/src/default/error.ts @@ -94,8 +94,10 @@ export default { 'exceededContext.title': 'Context Window Exceeded', 'unknownError.copyTraceId': 'Trace ID Copied', + 'unknownError.copyTraceIdTooltip': 'Click to copy', 'unknownError.desc': 'An unexpected error occurred. You can retry or report on', 'unknownError.retry': 'Retry', + 'unknownError.traceIdLabel': 'Trace ID:', 'unknownError.title': 'Oops, the request took a nap', 'response.ExceededContextWindowCloud': diff --git a/src/features/Conversation/Error/DeprecatedModelError.tsx b/src/features/Conversation/Error/DeprecatedModelError.tsx new file mode 100644 index 0000000000..021614f30c --- /dev/null +++ b/src/features/Conversation/Error/DeprecatedModelError.tsx @@ -0,0 +1,26 @@ +import { Icon } from '@lobehub/ui'; +import { AlertTriangle } from 'lucide-react'; +import { memo } from 'react'; +import { useTranslation } from 'react-i18next'; + +import BaseErrorForm from '@/features/Conversation/Error/BaseErrorForm'; + +interface DeprecatedModelErrorProps { + requestedModel?: string; +} + +const DeprecatedModelError = memo(({ requestedModel }) => { + const { t } = useTranslation('error'); + + return ( + } + title={t('fetchError.title')} + desc={t('response.LobeHubModelDeprecated', { + model: requestedModel ?? '-', + })} + /> + ); +}); + +export default DeprecatedModelError; diff --git a/src/features/Conversation/Error/ExceededContextWindowError.tsx b/src/features/Conversation/Error/ExceededContextWindowError.tsx index a82d88c02e..4b128a8ebf 100644 --- a/src/features/Conversation/Error/ExceededContextWindowError.tsx +++ b/src/features/Conversation/Error/ExceededContextWindowError.tsx @@ -1,7 +1,7 @@ import { Icon } from '@lobehub/ui'; import { Button } from 'antd'; import { Minimize2 } from 'lucide-react'; -import { memo, useCallback, useState } from 'react'; +import { memo, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { usePermission } from '@/hooks/usePermission'; @@ -9,6 +9,7 @@ import { useChatStore } from '@/store/chat'; import { useConversationStore } from '../store'; import BaseErrorForm from './BaseErrorForm'; +import { useRetryParentMessage } from './useRetryParentMessage'; interface ExceededContextWindowErrorProps { id: string; @@ -16,26 +17,16 @@ interface ExceededContextWindowErrorProps { const ExceededContextWindowError = memo(({ id }) => { const { t } = useTranslation('error'); - const [loading, setLoading] = useState(false); const { allowed: canCreate } = usePermission('create_content'); const context = useConversationStore((s) => s.context); - const regenerateUserMessage = useConversationStore((s) => s.regenerateUserMessage); - const parentId = useConversationStore( - (s) => s.displayMessages.find((m) => m.id === id)?.parentId, - ); + const { disabled, loading, retryParentMessage } = useRetryParentMessage(id); const handleCompact = useCallback(async () => { - if (!canCreate || !context.topicId || !parentId) return; + if (!canCreate || !context.topicId) return; - setLoading(true); - try { - await useChatStore.getState().executeCompression(context, ''); - await regenerateUserMessage(parentId); - } finally { - setLoading(false); - } - }, [canCreate, context, parentId, regenerateUserMessage]); + await retryParentMessage(() => useChatStore.getState().executeCompression(context, '')); + }, [canCreate, context, retryParentMessage]); return ( (({ id } title={t('exceededContext.title')} action={ + + )} + + + + + ); +}); + +export default PlanLimitCard; diff --git a/src/features/Conversation/Error/QuotaLimitError.tsx b/src/features/Conversation/Error/QuotaLimitError.tsx new file mode 100644 index 0000000000..81a20ae825 --- /dev/null +++ b/src/features/Conversation/Error/QuotaLimitError.tsx @@ -0,0 +1,39 @@ +import { Icon } from '@lobehub/ui'; +import { Button } from 'antd'; +import { AlertTriangle, RotateCw } from 'lucide-react'; +import { memo } from 'react'; +import { useTranslation } from 'react-i18next'; + +import BaseErrorForm from '@/features/Conversation/Error/BaseErrorForm'; + +import { useRetryParentMessage } from './useRetryParentMessage'; + +interface QuotaLimitErrorProps { + id: string; +} + +const QuotaLimitError = memo(({ id }) => { + const { t } = useTranslation('error'); + const { disabled, loading, retryParentMessage } = useRetryParentMessage(id); + + return ( + } + title={t('response.QuotaLimitReachedCloud')} + action={ + + } + /> + ); +}); + +export default QuotaLimitError; diff --git a/src/features/Conversation/Error/TraceIdError.tsx b/src/features/Conversation/Error/TraceIdError.tsx new file mode 100644 index 0000000000..aa7ec5ba66 --- /dev/null +++ b/src/features/Conversation/Error/TraceIdError.tsx @@ -0,0 +1,88 @@ +import { SOCIAL_URL } from '@lobechat/business-const'; +import { copyToClipboard, Icon } from '@lobehub/ui'; +import { DiscordIcon } from '@lobehub/ui/icons'; +import { Button, message } from 'antd'; +import { cssVar } from 'antd-style'; +import { AlertTriangle, Copy, RotateCw } from 'lucide-react'; +import { memo, useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; + +import BaseErrorForm from '@/features/Conversation/Error/BaseErrorForm'; + +import { useRetryParentMessage } from './useRetryParentMessage'; + +interface TraceIdErrorProps { + id: string; + traceId: string; +} + +const TraceIdError = memo(({ id, traceId }) => { + const { t } = useTranslation('error'); + const { disabled, loading, retryParentMessage } = useRetryParentMessage(id); + + const handleCopyTraceId = useCallback(async () => { + try { + await copyToClipboard(traceId); + message.success(t('unknownError.copyTraceId')); + } catch { + /* noop */ + } + }, [t, traceId]); + + return ( + } + title={t('unknownError.title')} + action={ + + } + desc={ + + {t('unknownError.desc')}{' '} + + + Discord + + {' · '} + {t('unknownError.traceIdLabel')}{' '} + + {traceId} + + + + } + /> + ); +}); + +export default TraceIdError; diff --git a/src/features/Conversation/Error/index.test.tsx b/src/features/Conversation/Error/index.test.tsx index 38d3945807..4bd1bb2fca 100644 --- a/src/features/Conversation/Error/index.test.tsx +++ b/src/features/Conversation/Error/index.test.tsx @@ -5,12 +5,14 @@ import type * as lobechatTypesModule from '@lobechat/types'; import type * as lobehubUiModule from '@lobehub/ui'; import { render, screen } from '@testing-library/react'; import type { ReactNode } from 'react'; -import { describe, expect, it, vi } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; import ErrorMessageExtra from './index'; const navigateMock = vi.fn(); +const serverConfigMock = vi.hoisted(() => ({ enableBusinessFeatures: false })); + vi.mock('@lobechat/business-const', async (importOriginal) => { const actual = (await importOriginal()) as typeof businessConstModule; @@ -105,7 +107,7 @@ vi.mock('@/libs/next/dynamic', () => ({ vi.mock('@/store/serverConfig', () => ({ serverConfigSelectors: { - enableBusinessFeatures: () => false, + enableBusinessFeatures: () => serverConfigMock.enableBusinessFeatures, }, useServerConfigStore: (selector: (s: unknown) => unknown) => selector({}), })); @@ -119,6 +121,50 @@ vi.mock('@/features/Conversation/store', () => ({ })); describe('ErrorMessageExtra', () => { + beforeEach(() => { + serverConfigMock.enableBusinessFeatures = false; + }); + + it('keeps the localized message for known error types even when a traceId exists', () => { + serverConfigMock.enableBusinessFeatures = true; + + render( + , + ); + + // Not swallowed by the TraceIdError fallback (rendered via mocked dynamic) + expect(screen.queryByText('dynamic')).not.toBeInTheDocument(); + expect(screen.getByText('response.LocationNotSupportError')).toBeInTheDocument(); + }); + + it('shows the trace-id report UI for unknown traceable errors', () => { + serverConfigMock.enableBusinessFeatures = true; + + render( + , + ); + + expect(screen.getByText('dynamic')).toBeInTheDocument(); + }); + it('renders the auth guide when the refreshed error is missing type but still carries session code', () => { render( import('./OllamaSetupGuide'), { ssr: false, }); +const PlanLimitCard = dynamic(() => import('./PlanLimitCard'), { loading, ssr: false }); + +const DeprecatedModelError = dynamic(() => import('./DeprecatedModelError'), { + loading, + ssr: false, +}); + +const QuotaLimitError = dynamic(() => import('./QuotaLimitError'), { loading, ssr: false }); + +const TraceIdError = dynamic(() => import('./TraceIdError'), { loading, ssr: false }); + const HETEROGENEOUS_AGENT_STATUS_GUIDE_ERROR_CODES = new Set([ HeterogeneousAgentSessionErrorCode.AuthRequired, HeterogeneousAgentSessionErrorCode.CliNotFound, @@ -83,6 +94,28 @@ const HETEROGENEOUS_AGENT_STATUS_GUIDE_ERROR_CODES = new Set([ HeterogeneousAgentSessionErrorCode.RateLimit, ]); +// `UnknownChatFetchError` is excluded: its localized copy is a generic +// "unknown error" message, so the trace-id report UI is strictly more useful. +const LEGACY_LOCALIZED_ERROR_TYPES = new Set( + Object.values(ChatErrorType) + .map(String) + .filter((type) => type !== ChatErrorType.UnknownChatFetchError), +); + +/** + * Whether `getRuntimeErrorMessage` resolves a dedicated localized message for + * this error type — known runtime codes (spec table) plus legacy + * `error:response.` entries (ChatErrorType members and HTTP status codes). + */ +const hasLocalizedErrorMessage = ( + errorType?: IToolErrorType | ILobeAgentRuntimeErrorType | ErrorType, +): boolean => { + if (errorType === undefined || errorType === null) return false; + if (typeof errorType === 'number') return true; + if (getErrorCodeSpec(String(errorType))) return true; + return LEGACY_LOCALIZED_ERROR_TYPES.has(String(errorType)); +}; + const isHeterogeneousAgentStatusGuideError = ( value: unknown, ): value is HeterogeneousAgentSessionError => { @@ -213,6 +246,34 @@ const ErrorMessageExtra = memo(({ error: alertError, data, onRe if (enableBusinessFeatures && businessChatErrorMessageExtra) return businessChatErrorMessageExtra; switch (error?.type) { + // Lightweight fallbacks for cloud billing errors, used in builds without a + // business override (e.g. desktop). The business hook above takes + // precedence when installed. + case ChatErrorType.FreePlanLimit: + case ChatErrorType.SubscriptionPlanLimit: + case ChatErrorType.InsufficientBudgetForModel: { + if (enableBusinessFeatures) + return ( + + ); + break; + } + + case ChatErrorType.LobeHubModelDeprecated: { + if (enableBusinessFeatures) + return ; + break; + } + + case AgentRuntimeErrorType.QuotaLimitReached: { + if (enableBusinessFeatures) return ; + break; + } + case AgentRuntimeErrorType.OllamaServiceUnavailable: { return ; } @@ -236,6 +297,16 @@ const ErrorMessageExtra = memo(({ error: alertError, data, onRe return ; } + // Show a report action for unknown traceable errors instead of the raw body. + // Error types with a dedicated localized message keep the ErrorContent below. + if ( + enableBusinessFeatures && + !hasLocalizedErrorMessage(error?.type) && + typeof error?.body?.traceId === 'string' + ) { + return ; + } + return ( ({ + displayMessages: [] as { id: string; parentId?: string }[], + regenerateUserMessage: vi.fn(async (_id: string) => {}), +})); + +vi.mock('@/features/Conversation/store', () => ({ + useConversationStore: ( + selector: (state: { + displayMessages: { id: string; parentId?: string }[]; + regenerateUserMessage: typeof storeMock.regenerateUserMessage; + }) => unknown, + ) => + selector({ + displayMessages: storeMock.displayMessages, + regenerateUserMessage: storeMock.regenerateUserMessage, + }), +})); + +describe('useRetryParentMessage', () => { + beforeEach(() => { + storeMock.displayMessages = [{ id: 'assistant-message', parentId: 'user-message' }]; + storeMock.regenerateUserMessage.mockClear(); + }); + + it('should retry the parent message', async () => { + const { result } = renderHook(() => useRetryParentMessage('assistant-message')); + + await act(async () => { + await result.current.retryParentMessage(); + }); + + expect(result.current.disabled).toBe(false); + expect(storeMock.regenerateUserMessage).toHaveBeenCalledWith('user-message'); + }); + + it('should run the pre-retry action before regenerating the parent message', async () => { + const calls: string[] = []; + const beforeRetry = vi.fn(async () => { + calls.push('before'); + }); + storeMock.regenerateUserMessage.mockImplementationOnce(async () => { + calls.push('regenerate'); + }); + + const { result } = renderHook(() => useRetryParentMessage('assistant-message')); + + await act(async () => { + await result.current.retryParentMessage(beforeRetry); + }); + + expect(beforeRetry).toHaveBeenCalledTimes(1); + expect(calls).toEqual(['before', 'regenerate']); + }); + + it('should skip retry when the message has no parent', async () => { + storeMock.displayMessages = [{ id: 'assistant-message' }]; + const { result } = renderHook(() => useRetryParentMessage('assistant-message')); + + await act(async () => { + await result.current.retryParentMessage(); + }); + + expect(result.current.disabled).toBe(true); + expect(storeMock.regenerateUserMessage).not.toHaveBeenCalled(); + }); +}); diff --git a/src/features/Conversation/Error/useRetryParentMessage.ts b/src/features/Conversation/Error/useRetryParentMessage.ts new file mode 100644 index 0000000000..dcf219c6b1 --- /dev/null +++ b/src/features/Conversation/Error/useRetryParentMessage.ts @@ -0,0 +1,35 @@ +import { useCallback, useState } from 'react'; + +import { useConversationStore } from '@/features/Conversation/store'; + +type BeforeRetry = () => Promise | void; + +export const useRetryParentMessage = (id: string) => { + const [loading, setLoading] = useState(false); + + const regenerateUserMessage = useConversationStore((s) => s.regenerateUserMessage); + const parentId = useConversationStore( + (s) => s.displayMessages.find((m) => m.id === id)?.parentId, + ); + + const retryParentMessage = useCallback( + async (beforeRetry?: BeforeRetry) => { + if (!parentId) return; + + setLoading(true); + try { + await beforeRetry?.(); + await regenerateUserMessage(parentId); + } finally { + setLoading(false); + } + }, + [parentId, regenerateUserMessage], + ); + + return { + disabled: !parentId, + loading, + retryParentMessage, + }; +};