diff --git a/web/AGENTS.md b/web/AGENTS.md index cee313ba1..a2879b26a 100644 --- a/web/AGENTS.md +++ b/web/AGENTS.md @@ -92,14 +92,14 @@ 以下为常用检索入口,不能把本表当作完整组件清单: -| 场景 | 优先检查的项目入口 | -| --- | --- | -| 通用弹窗布局 | `@/components/dialog` | -| 删除、危险操作及普通确认 | `@/components/confirm-dialog` | -| 复制按钮与剪贴板交互 | `@/components/copy-button`、`@/hooks/use-copy-to-clipboard` | -| 空状态、加载状态、错误状态 | `@/components/empty-state`、`@/components/loading-state`、`@/components/error-state` | -| 表格、分页、工具栏及列表布局 | `@/components/data-table`,先读该目录的 `README.md` 和公开导出 | -| 按钮、输入、选择、提示等基础控件 | `@/components/ui/`,以 `components.json` 和本地实现为准 | +| 场景 | 优先检查的项目入口 | +| -------------------------------- | ------------------------------------------------------------------------------------ | +| 通用弹窗布局 | `@/components/dialog` | +| 删除、危险操作及普通确认 | `@/components/confirm-dialog` | +| 复制按钮与剪贴板交互 | `@/components/copy-button`、`@/hooks/use-copy-to-clipboard` | +| 空状态、加载状态、错误状态 | `@/components/empty-state`、`@/components/loading-state`、`@/components/error-state` | +| 表格、分页、工具栏及列表布局 | `@/components/data-table`,先读该目录的 `README.md` 和公开导出 | +| 按钮、输入、选择、提示等基础控件 | `@/components/ui/`,以 `components.json` 和本地实现为准 | - 使用函数式组件与 Hooks,单一职责;组件 props 须有明确类型(接口或类型别名)。 - **Props 使用**:组件 props 非必要不要解构,直接使用 `props.xxx` 访问属性,保持代码清晰(详见 [3.2 代码风格与类型](#32-代码风格与类型))。 @@ -121,7 +121,7 @@ ### 3.6 API 请求 - **React Query**:数据获取用 `useQuery`,变更用 `useMutation`;为每个查询配置唯一 `queryKey`(建议数组形式、层级一致);在 `onSuccess` 中对相关 query 做 `invalidateQueries`,可配合乐观更新。服务端错误统一通过 `handleServerError` 处理(详见 [3.9 错误处理](#39-错误处理))。 -- **Axios**:使用项目统一的 `api` 实例(含 `baseURL`、`headers`、`withCredentials: true`);GET 默认请求去重,特殊请求可通过配置关闭;认证与通用错误在拦截器中处理。 +- **Axios**:使用项目统一的 `api` 实例(含 `baseURL`、`headers`、`withCredentials: true`);GET 默认请求去重,特殊请求可通过配置关闭;认证刷新与请求重试在拦截器中处理;普通错误提示由 Query/Mutation 的最终失败回调或直接调用方负责。 ### 3.7 表单 @@ -136,7 +136,10 @@ ### 3.9 错误处理 -- **服务端错误**:统一使用 `handleServerError`,在 React Query 全局配置与拦截器中接入;按 HTTP 状态码给出合适提示,文案使用 i18n。 +- **服务端错误**:统一使用 `handleServerError(error, fallbackMessage?)`,优先保留服务端具体原因。相同失败沿 `cause` 传播时只提示一次;不同操作即使文案相同也分别提示。禁止把完整 Axios 错误对象(可能包含凭据)直接写入控制台。 +- **提示归属**:普通请求拦截器不弹 Toast。使用共享 `createAppQueryClient` 在 Query/Mutation 最终失败时提示;Mutation 自定义 `onError` 负责自己的提示或表单错误。自动重试期间和主动取消时不报错。`meta.errorToast: false` 关闭自动提示,页面仍可主动调用错误处理函数。 +- **业务失败**:保留原始 API 响应契约;Query 使用 `requireServerSuccess` 检查 `success: false`,或在业务层用 `createServerError(response, fallbackMessage?)` 抛错并保留来源。直接请求须明确处理失败响应和 Promise 拒绝;需要展示失败状态的 Mutation 不得把失败结果当作保存成功。 +- **认证兼容**:保留稳定错误码映射及 `AuthOperationError` 的安全消息,不解开其来源来展示被屏蔽的细节。维护既有刷新、跳转和一次性授权不重放约束。 - **展示**:使用 `toast.error` 等统一方式;路由级错误由 `errorComponent` 承接,提供友好错误页并记录便于排查的信息。 - **表单**:校验与服务端错误映射到字段后,在字段下方展示;使用 `form.setError` 等与表单库一致的方式。 diff --git a/web/src/components/layout/components/chat-presets-item.tsx b/web/src/components/layout/components/chat-presets-item.tsx index f1e206936..7c3c917e0 100644 --- a/web/src/components/layout/components/chat-presets-item.tsx +++ b/web/src/components/layout/components/chat-presets-item.tsx @@ -48,6 +48,7 @@ import { resolveChatUrl, type ChatPreset, } from '@/features/chat/lib/chat-links' +import { handleServerError } from '@/lib/handle-server-error' import { normalizeHref } from '../lib/url-utils' import type { NavChatPresets } from '../types' @@ -194,7 +195,7 @@ export function ChatPresetsItem({ item }: { item: NavChatPresets }) { : t( 'Unable to prepare chat link. Please ensure you have an enabled API key.' ) - toast.error(message) + handleServerError(error, message) return } finally { loadingPresetIdRef.current = null diff --git a/web/src/components/sign-out-dialog.tsx b/web/src/components/sign-out-dialog.tsx index 9479c9c00..cece88c20 100644 --- a/web/src/components/sign-out-dialog.tsx +++ b/web/src/components/sign-out-dialog.tsx @@ -25,6 +25,7 @@ import { toast } from 'sonner' import { ConfirmDialog } from '@/components/confirm-dialog' import { logout } from '@/features/auth/api' import { clearAuthenticatedClientState } from '@/lib/auth-session' +import { handleServerError } from '@/lib/handle-server-error' interface SignOutDialogProps { open: boolean @@ -42,7 +43,7 @@ export function SignOutDialog({ open, onOpenChange }: SignOutDialogProps) { try { const response = await logout() if (!response.success) { - toast.error(response.message || t('Failed to sign out session')) + handleServerError(response, t('Failed to sign out session')) return } @@ -50,9 +51,7 @@ export function SignOutDialog({ open, onOpenChange }: SignOutDialogProps) { toast.success(t('Signed out')) void navigate({ to: '/sign-in', replace: true }) } catch (error: unknown) { - toast.error( - error instanceof Error ? error.message : t('Failed to sign out session') - ) + handleServerError(error, t('Failed to sign out session')) } finally { setIsSigningOut(false) } diff --git a/web/src/features/about/index.tsx b/web/src/features/about/index.tsx index 228343050..e6ca22d47 100644 --- a/web/src/features/about/index.tsx +++ b/web/src/features/about/index.tsx @@ -24,6 +24,7 @@ import { PublicLayout } from '@/components/layout' import { RichContent } from '@/components/rich-content' import { Skeleton } from '@/components/ui/skeleton' import { isHttpUrl, isLikelyHtml } from '@/lib/content-format' +import { requireServerSuccess } from '@/lib/server-error-message' import { getAboutContent } from './api' @@ -116,7 +117,7 @@ export function About() { const { t } = useTranslation() const { data, isLoading } = useQuery({ queryKey: ['about-content'], - queryFn: getAboutContent, + queryFn: async () => requireServerSuccess(await getAboutContent()), }) const rawContent = data?.data?.trim() ?? '' diff --git a/web/src/features/auth/forgot-password/components/forgot-password-form.tsx b/web/src/features/auth/forgot-password/components/forgot-password-form.tsx index 9ba93321d..4d5e849ed 100644 --- a/web/src/features/auth/forgot-password/components/forgot-password-form.tsx +++ b/web/src/features/auth/forgot-password/components/forgot-password-form.tsx @@ -42,6 +42,9 @@ import { } from '@/features/auth/constants' import { useTurnstile } from '@/features/auth/hooks/use-turnstile' import { useCountdown } from '@/hooks/use-countdown' +import { handleServerError } from '@/lib/handle-server-error' +import { AuthOperationError } from '@/lib/secure-verification' +import { createServerError } from '@/lib/server-error-message' import { cn } from '@/lib/utils' export function ForgotPasswordForm({ @@ -81,10 +84,14 @@ export function ForgotPasswordForm({ startCountdown() toast.success(t('Reset email sent, please check your inbox')) } else { - toast.error(res?.message || t('Failed to send reset email')) + handleServerError( + createServerError(res, t('Failed to send reset email')) + ) } } catch (_error) { - // Errors are handled by global interceptor + handleServerError( + AuthOperationError.from(_error, t('Failed to send reset email')) + ) } finally { setIsLoading(false) } diff --git a/web/src/features/auth/hooks/use-email-verification.ts b/web/src/features/auth/hooks/use-email-verification.ts index 7fe79b61a..1a20179a7 100644 --- a/web/src/features/auth/hooks/use-email-verification.ts +++ b/web/src/features/auth/hooks/use-email-verification.ts @@ -21,6 +21,9 @@ import { useState } from 'react' import { toast } from 'sonner' import { useCountdown } from '@/hooks/use-countdown' +import { handleServerError } from '@/lib/handle-server-error' +import { AuthOperationError } from '@/lib/secure-verification' +import { createServerError } from '@/lib/server-error-message' import { sendEmailVerification } from '../api' import { EMAIL_VERIFICATION_COUNTDOWN } from '../constants' @@ -63,12 +66,17 @@ export function useEmailVerification(options?: UseEmailVerificationOptions) { toast.success(i18next.t('Verification email sent')) return true } - toast.error( - res?.message || i18next.t('Failed to send verification email') + handleServerError( + createServerError(res, i18next.t('Failed to send verification email')) ) return false } catch (_error) { - // Errors are handled by global interceptor + handleServerError( + AuthOperationError.from( + _error, + i18next.t('Failed to send verification email') + ) + ) return false } finally { setIsSending(false) diff --git a/web/src/features/auth/hooks/use-oauth-login.ts b/web/src/features/auth/hooks/use-oauth-login.ts index 68b219302..f2c129366 100644 --- a/web/src/features/auth/hooks/use-oauth-login.ts +++ b/web/src/features/auth/hooks/use-oauth-login.ts @@ -21,7 +21,9 @@ import { useTranslation } from 'react-i18next' import { toast } from 'sonner' import { clearAuthentication } from '@/lib/api' +import { handleServerError } from '@/lib/handle-server-error' import { AuthOperationError } from '@/lib/secure-verification' +import { createServerError } from '@/lib/server-error-message' import { createOAuthAuthorization, createOAuthFlow, logout } from '../api' import { @@ -59,7 +61,7 @@ export function useOAuthLogin( const resetSession = async () => { const response = await logout() if (!response.success) { - throw new Error(response.message || t('Failed to sign out session')) + throw createServerError(response, t('Failed to sign out session')) } clearAuthentication() } @@ -91,8 +93,10 @@ export function useOAuthLogin( const url = buildGitHubOAuthUrl(status.github_client_id, state) window.open(url, '_self') - } catch { - toast.error(t('Failed to start GitHub login')) + } catch (error) { + handleServerError( + AuthOperationError.from(error, t('Failed to start GitHub login')) + ) if (githubTimeoutRef.current) { clearTimeout(githubTimeoutRef.current) } @@ -113,8 +117,10 @@ export function useOAuthLogin( const url = buildDiscordOAuthUrl(status.discord_client_id, state) window.open(url, '_self') - } catch { - toast.error(t('Failed to start Discord login')) + } catch (error) { + handleServerError( + AuthOperationError.from(error, t('Failed to start Discord login')) + ) } finally { setIsLoading(false) } @@ -135,8 +141,10 @@ export function useOAuthLogin( state ) window.open(url, '_self') - } catch { - toast.error(t('Failed to start OIDC login')) + } catch (error) { + handleServerError( + AuthOperationError.from(error, t('Failed to start OIDC login')) + ) } finally { setIsLoading(false) } @@ -153,8 +161,10 @@ export function useOAuthLogin( const url = buildLinuxDOOAuthUrl(status.linuxdo_client_id, state) window.open(url, '_self') - } catch { - toast.error(t('Failed to start LinuxDO login')) + } catch (error) { + handleServerError( + AuthOperationError.from(error, t('Failed to start LinuxDO login')) + ) } finally { setIsLoading(false) } @@ -179,7 +189,7 @@ export function useOAuthLogin( rememberOAuthLoginRedirect(authorization.state, redirectTo) window.open(authorization.authorizationUrl, '_self') } catch (error) { - toast.error(t(AuthOperationError.from(error).message)) + handleServerError(AuthOperationError.from(error)) } finally { setIsLoading(false) } @@ -205,9 +215,12 @@ export function useOAuthLogin( } window.open(url.toString(), '_self') - } catch { - toast.error( - t('Failed to start {{provider}} login', { provider: provider.name }) + } catch (error) { + handleServerError( + AuthOperationError.from( + error, + t('Failed to start {{provider}} login', { provider: provider.name }) + ) ) } finally { setIsLoading(false) diff --git a/web/src/features/auth/otp/components/otp-form.tsx b/web/src/features/auth/otp/components/otp-form.tsx index 274774410..c45d07550 100644 --- a/web/src/features/auth/otp/components/otp-form.tsx +++ b/web/src/features/auth/otp/components/otp-form.tsx @@ -20,6 +20,7 @@ import { useEffect, useRef } from 'react' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' +import { handleServerError } from '@/lib/handle-server-error' import { AuthOperationError } from '@/lib/secure-verification' import { useAuthStore } from '@/stores/auth-store' @@ -77,7 +78,7 @@ export function OtpForm() { toast.success(t('Signed in')) } catch (error) { if (!active) return - toast.error(t(AuthOperationError.from(error).message)) + handleServerError(AuthOperationError.from(error)) redirectToLogin() } })() diff --git a/web/src/features/auth/reset-password-confirm/index.tsx b/web/src/features/auth/reset-password-confirm/index.tsx index 5109af33d..e844fe527 100644 --- a/web/src/features/auth/reset-password-confirm/index.tsx +++ b/web/src/features/auth/reset-password-confirm/index.tsx @@ -29,6 +29,9 @@ import { Label } from '@/components/ui/label' import { useCountdown } from '@/hooks/use-countdown' import { api } from '@/lib/api' import { copyToClipboard } from '@/lib/copy-to-clipboard' +import { handleServerError } from '@/lib/handle-server-error' +import { AuthOperationError } from '@/lib/secure-verification' +import { createServerError } from '@/lib/server-error-message' import { AuthLayout } from '../auth-layout' @@ -82,9 +85,11 @@ export function ResetPasswordConfirm({ } else { toast.success(t('Password reset: {{password}}', { password })) } + } else { + handleServerError(createServerError(res.data, t('Request failed'))) } - } catch { - // Errors handled by global interceptor + } catch (error) { + handleServerError(AuthOperationError.from(error)) } finally { setLoading(false) } @@ -179,13 +184,15 @@ export function ResetPasswordConfirm({ newPassword ? false : loading || isActive || !isValidResetLink } > - {newPassword - ? t('auth.resetPasswordConfirm.backToLogin') - : isActive - ? t('auth.resetPasswordConfirm.retry', { - seconds: secondsLeft, - }) - : t('auth.resetPasswordConfirm.confirm')} + {newPassword && t('auth.resetPasswordConfirm.backToLogin')} + {!newPassword && + isActive && + t('auth.resetPasswordConfirm.retry', { + seconds: secondsLeft, + })} + {!newPassword && + !isActive && + t('auth.resetPasswordConfirm.confirm')} {!newPassword && ( diff --git a/web/src/features/auth/sign-in/components/user-auth-form.tsx b/web/src/features/auth/sign-in/components/user-auth-form.tsx index 15c3383b8..5f97a43eb 100644 --- a/web/src/features/auth/sign-in/components/user-auth-form.tsx +++ b/web/src/features/auth/sign-in/components/user-auth-form.tsx @@ -18,7 +18,6 @@ For commercial licensing, please contact support@quantumnous.com */ import { zodResolver } from '@hookform/resolvers/zod' import { Link } from '@tanstack/react-router' -import axios from 'axios' import { Loader2, LogIn, KeyRound } from 'lucide-react' import { useEffect, useMemo, useState } from 'react' import { useForm } from 'react-hook-form' @@ -49,12 +48,14 @@ import { useTurnstile } from '@/features/auth/hooks/use-turnstile' import { beginPasskeyLogin, finishPasskeyLogin } from '@/features/auth/passkey' import type { AuthFormProps } from '@/features/auth/types' import { useStatus } from '@/hooks/use-status' +import { handleServerError } from '@/lib/handle-server-error' import { buildAssertionResult, prepareCredentialRequestOptions, isPasskeySupported as detectPasskeySupport, } from '@/lib/passkey' -import { getServerErrorMessageKey } from '@/lib/server-error-message' +import { AuthOperationError } from '@/lib/secure-verification' +import { createServerError } from '@/lib/server-error-message' import { cn } from '@/lib/utils' export function UserAuthForm({ @@ -178,10 +179,11 @@ export function UserAuthForm({ if (await handleLoginResult(res.data, redirectTo)) { toast.success(t('Welcome back!')) } + } else { + handleServerError(createServerError(res, loginFailedMessage)) } } catch (error: unknown) { - if (axios.isAxiosError(error)) return - toast.error(error instanceof Error ? error.message : loginFailedMessage) + handleServerError(AuthOperationError.from(error, loginFailedMessage)) } finally { setIsLoading(false) } @@ -219,12 +221,12 @@ export function UserAuthForm({ toast.success(t('Signed in via WeChat')) } } else { - if (getServerErrorMessageKey(res)) return - toast.error(res?.message || loginFailedMessage) + handleServerError(createServerError(res, loginFailedMessage)) } } catch (error: unknown) { - if (getServerErrorMessageKey(error)) return - toast.error(loginFailedMessage) + handleServerError( + new AuthOperationError(loginFailedMessage, undefined, { cause: error }) + ) } finally { setIsWeChatSubmitting(false) } @@ -250,8 +252,7 @@ export function UserAuthForm({ try { const begin = await beginPasskeyLogin() if (!begin.success) { - if (getServerErrorMessageKey(begin)) return - throw new Error(begin.message || t('Failed to start Passkey login')) + throw createServerError(begin, t('Failed to start Passkey login')) } const publicKey = prepareCredentialRequestOptions( @@ -278,21 +279,21 @@ export function UserAuthForm({ const finish = await finishPasskeyLogin(flowToken, assertion) if (!finish.success) { - if (getServerErrorMessageKey(finish)) return - throw new Error(finish.message || t('Failed to complete Passkey login')) + throw createServerError(finish, t('Failed to complete Passkey login')) } if (await handleLoginResult(finish.data, redirectTo)) { toast.success(t('Signed in with Passkey')) } } catch (error: unknown) { - if (getServerErrorMessageKey(error)) return if (error instanceof DOMException && error.name === 'NotAllowedError') { toast.info(t('Passkey login was cancelled or timed out')) } else if (error instanceof Error) { - toast.error(error.message) + handleServerError(AuthOperationError.from(error)) } else { - toast.error(t('Passkey login failed')) + handleServerError( + AuthOperationError.from(error, t('Passkey login failed')) + ) } } finally { setIsPasskeyLoading(false) diff --git a/web/src/features/auth/sign-up/components/sign-up-form.tsx b/web/src/features/auth/sign-up/components/sign-up-form.tsx index 5de64fdd5..6dbffb25f 100644 --- a/web/src/features/auth/sign-up/components/sign-up-form.tsx +++ b/web/src/features/auth/sign-up/components/sign-up-form.tsx @@ -50,7 +50,9 @@ import { saveAffiliateCode, } from '@/features/auth/lib/storage' import { useStatus } from '@/hooks/use-status' -import { getServerErrorMessageKey } from '@/lib/server-error-message' +import { handleServerError } from '@/lib/handle-server-error' +import { AuthOperationError } from '@/lib/secure-verification' +import { createServerError } from '@/lib/server-error-message' import { cn } from '@/lib/utils' export function SignUpForm({ @@ -172,10 +174,12 @@ export function SignUpForm({ toast.success(t('Account created! Please sign in')) redirectToLogin() } else { - toast.error(res?.message || t('Failed to create account')) + handleServerError(createServerError(res, t('Failed to create account'))) } - } catch { - // Errors are handled by global interceptor + } catch (error) { + handleServerError( + AuthOperationError.from(error, t('Failed to create account')) + ) } finally { setIsLoading(false) } @@ -220,12 +224,12 @@ export function SignUpForm({ toast.success(t('Signed in via WeChat')) } } else { - if (getServerErrorMessageKey(res)) return - toast.error(res?.message || t('Login failed')) + handleServerError(createServerError(res, t('Login failed'))) } } catch (error: unknown) { - if (getServerErrorMessageKey(error)) return - toast.error(t('Login failed')) + handleServerError( + new AuthOperationError(t('Login failed'), undefined, { cause: error }) + ) } finally { setIsWeChatSubmitting(false) } diff --git a/web/src/features/channels/api.ts b/web/src/features/channels/api.ts index bb411922b..9f47d92ed 100644 --- a/web/src/features/channels/api.ts +++ b/web/src/features/channels/api.ts @@ -18,6 +18,7 @@ For commercial licensing, please contact support@quantumnous.com */ import { getGroups as getUserGroups } from '@/features/users/api' import { api, type ApiRequestConfig } from '@/lib/api' +import { requireServerSuccess } from '@/lib/server-error-message' import type { AddChannelRequest, @@ -64,7 +65,7 @@ export async function getTaskPluginOptions(): Promise { success: boolean data: TaskPluginOption[] }>('/api/task_plugin_options') - return response.data.data + return requireServerSuccess(response.data).data } export type CodexUsageResponse = { diff --git a/web/src/features/channels/components/channel-type-badge.tsx b/web/src/features/channels/components/channel-type-badge.tsx index 99f51b684..e721ad0a5 100644 --- a/web/src/features/channels/components/channel-type-badge.tsx +++ b/web/src/features/channels/components/channel-type-badge.tsx @@ -30,6 +30,7 @@ import { hasPermission, } from '@/lib/admin-permissions' import { getLobeIcon } from '@/lib/lobe-icon' +import { requireServerSuccess } from '@/lib/server-error-message' import { cn } from '@/lib/utils' import { useAuthStore } from '@/stores/auth-store' @@ -77,7 +78,7 @@ export function TaskPluginChannelBadge(props: { pluginKey?: string }) { ) const query = useQuery({ queryKey: ['task-plugin-options'], - queryFn: getTaskPluginOptions, + queryFn: async () => requireServerSuccess(await getTaskPluginOptions()), enabled: Boolean(props.pluginKey) && canBind, staleTime: 60 * 1000, }) diff --git a/web/src/features/channels/components/channels-columns.tsx b/web/src/features/channels/components/channels-columns.tsx index 4fbb4f3af..34ce59152 100644 --- a/web/src/features/channels/components/channels-columns.tsx +++ b/web/src/features/channels/components/channels-columns.tsx @@ -53,6 +53,8 @@ import { getCurrencyLabel, } from '@/lib/currency' import { formatTimestampToDate } from '@/lib/format' +import { handleServerError } from '@/lib/handle-server-error' +import { createServerError } from '@/lib/server-error-message' import { truncateText } from '@/lib/utils' import { getCodexUsage, updateChannelBalance } from '../api' @@ -437,14 +439,12 @@ export function BalanceCell({ channel }: { channel: Channel }) { try { const res = await getCodexUsage(channel.id) if (!res.success) { - throw new Error(res.message || t('Failed to fetch usage')) + throw createServerError(res, t('Failed to fetch usage')) } setCodexUsageResponse(res) setCodexUsageOpen(true) } catch (error) { - toast.error( - error instanceof Error ? error.message : t('Failed to fetch usage') - ) + handleServerError(error, t('Failed to fetch usage')) } finally { setIsUpdating(false) } @@ -470,12 +470,10 @@ export function BalanceCell({ channel }: { channel: Channel }) { setCurrentRow(channel) setRawBalanceResponse(response.raw_response) } else { - toast.error(response.message || t('Failed to update balance')) + handleServerError(response, t('Failed to update balance')) } } catch (error: unknown) { - toast.error( - error instanceof Error ? error.message : t('Failed to update balance') - ) + handleServerError(error, t('Failed to update balance')) } finally { setIsUpdating(false) } @@ -556,15 +554,11 @@ export function BalanceCell({ channel }: { channel: Channel }) { try { const res = await getCodexUsage(channel.id) if (!res.success) { - throw new Error(res.message || t('Failed to fetch usage')) + throw createServerError(res, t('Failed to fetch usage')) } setCodexUsageResponse(res) } catch (error) { - toast.error( - error instanceof Error - ? error.message - : t('Failed to fetch usage') - ) + handleServerError(error, t('Failed to fetch usage')) } finally { setIsUpdating(false) } diff --git a/web/src/features/channels/components/channels-table.tsx b/web/src/features/channels/components/channels-table.tsx index 6939d0866..53a1696cf 100644 --- a/web/src/features/channels/components/channels-table.tsx +++ b/web/src/features/channels/components/channels-table.tsx @@ -44,6 +44,7 @@ import { } from '@/components/ui/tooltip' import { useMediaQuery } from '@/hooks' import { useTableUrlState } from '@/hooks/use-table-url-state' +import { requireServerSuccess } from '@/lib/server-error-message' import { getChannels, searchChannels, getGroups } from '../api' import { @@ -205,7 +206,7 @@ export function ChannelsTable() { // Fetch groups for filter const { data: groupsData } = useQuery({ queryKey: ['groups'], - queryFn: getGroups, + queryFn: async () => requireServerSuccess(await getGroups()), }) const groupOptions = useMemo( @@ -243,47 +244,51 @@ export function ChannelsTable() { }), queryFn: async () => { if (shouldSearch) { - return searchChannels({ - keyword: globalFilter, - model: modelFilter, - group: - groupFilter.length > 0 && !groupFilter.includes('all') - ? groupFilter[0] - : undefined, - status: - statusFilter.length > 0 && !statusFilter.includes('all') - ? statusFilter[0] - : undefined, - type: - typeFilter.length > 0 && !typeFilter.includes('all') - ? Number(typeFilter[0]) - : undefined, - tag_mode: enableTagMode, - id_sort: idSort, - ...sortParams, - p: pagination.pageIndex + 1, - page_size: pagination.pageSize, - }) + return requireServerSuccess( + await searchChannels({ + keyword: globalFilter, + model: modelFilter, + group: + groupFilter.length > 0 && !groupFilter.includes('all') + ? groupFilter[0] + : undefined, + status: + statusFilter.length > 0 && !statusFilter.includes('all') + ? statusFilter[0] + : undefined, + type: + typeFilter.length > 0 && !typeFilter.includes('all') + ? Number(typeFilter[0]) + : undefined, + tag_mode: enableTagMode, + id_sort: idSort, + ...sortParams, + p: pagination.pageIndex + 1, + page_size: pagination.pageSize, + }) + ) } else { - return getChannels({ - group: - groupFilter.length > 0 && !groupFilter.includes('all') - ? groupFilter[0] - : undefined, - status: - statusFilter.length > 0 && !statusFilter.includes('all') - ? statusFilter[0] - : undefined, - type: - typeFilter.length > 0 && !typeFilter.includes('all') - ? Number(typeFilter[0]) - : undefined, - tag_mode: enableTagMode, - id_sort: idSort, - ...sortParams, - p: pagination.pageIndex + 1, - page_size: pagination.pageSize, - }) + return requireServerSuccess( + await getChannels({ + group: + groupFilter.length > 0 && !groupFilter.includes('all') + ? groupFilter[0] + : undefined, + status: + statusFilter.length > 0 && !statusFilter.includes('all') + ? statusFilter[0] + : undefined, + type: + typeFilter.length > 0 && !typeFilter.includes('all') + ? Number(typeFilter[0]) + : undefined, + tag_mode: enableTagMode, + id_sort: idSort, + ...sortParams, + p: pagination.pageIndex + 1, + page_size: pagination.pageSize, + }) + ) } }, placeholderData: (previousData) => previousData, diff --git a/web/src/features/channels/components/dialogs/balance-query-dialog.tsx b/web/src/features/channels/components/dialogs/balance-query-dialog.tsx index d5f60fe28..1d3c89120 100644 --- a/web/src/features/channels/components/dialogs/balance-query-dialog.tsx +++ b/web/src/features/channels/components/dialogs/balance-query-dialog.tsx @@ -32,6 +32,8 @@ import { Button } from '@/components/ui/button' import { IconBadge } from '@/components/ui/icon-badge' import { formatCurrencyFromUSD } from '@/lib/currency' import { formatTimestampToDate } from '@/lib/format' +import { handleServerError } from '@/lib/handle-server-error' +import { createServerError } from '@/lib/server-error-message' import { getCodexUsage, updateChannelBalance } from '../../api' import { channelsQueryKeys } from '../../lib' @@ -71,13 +73,11 @@ export function BalanceQueryDialog(props: BalanceQueryDialogProps) { try { const res = await getCodexUsage(row.id) if (!res.success) { - throw new Error(res.message || t('Failed to fetch usage')) + throw createServerError(res, t('Failed to fetch usage')) } setCodexUsageResponse(res) } catch (error: unknown) { - toast.error( - error instanceof Error ? error.message : t('Failed to fetch usage') - ) + handleServerError(error, t('Failed to fetch usage')) } finally { setIsQuerying(false) } @@ -119,12 +119,10 @@ export function BalanceQueryDialog(props: BalanceQueryDialogProps) { } else if (response.success && response.raw_response !== undefined) { setRawResponse(response.raw_response) } else { - toast.error(response.message || t('Failed to query balance')) + handleServerError(response, t('Failed to query balance')) } } catch (error: unknown) { - toast.error( - error instanceof Error ? error.message : t('Failed to query balance') - ) + handleServerError(error, t('Failed to query balance')) } finally { setIsQuerying(false) } diff --git a/web/src/features/channels/components/dialogs/channel-test-dialog.tsx b/web/src/features/channels/components/dialogs/channel-test-dialog.tsx index 682acf3a8..74ef4970b 100644 --- a/web/src/features/channels/components/dialogs/channel-test-dialog.tsx +++ b/web/src/features/channels/components/dialogs/channel-test-dialog.tsx @@ -16,7 +16,6 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ -import { Combobox } from '@/components/ui/combobox' import { useQueryClient } from '@tanstack/react-query' import type { ColumnDef, @@ -61,9 +60,9 @@ import { import { StatusBadge } from '@/components/status-badge' import { Button } from '@/components/ui/button' import { Checkbox } from '@/components/ui/checkbox' +import { Combobox } from '@/components/ui/combobox' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' - import { Sheet, SheetContent, @@ -80,6 +79,7 @@ import { } from '@/components/ui/tooltip' import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard' import { useIsMobile } from '@/hooks/use-mobile' +import { handleServerError } from '@/lib/handle-server-error' import { updateChannel } from '../../api' import { @@ -193,7 +193,6 @@ const endpointTypeOptions: Array<{ value: string; label: string }> = [ { value: 'embeddings', label: 'Embeddings (/v1/embeddings)' }, ] - const STREAM_INCOMPATIBLE_ENDPOINTS = new Set([ 'embeddings', 'image-generation', @@ -794,14 +793,10 @@ function ChannelTestDialogContent({ refreshChannelLists() setIsDeleteFailedDialogOpen(false) } else { - toast.error(response.message || t('Failed to delete failed models')) + handleServerError(response, t('Failed to delete failed models')) } } catch (error: unknown) { - toast.error( - error instanceof Error - ? error.message - : t('Failed to delete failed models') - ) + handleServerError(error, t('Failed to delete failed models')) } finally { setIsDeletingFailed(false) } @@ -988,13 +983,13 @@ function ChannelTestDialogContent({
+ options={endpointSelectItems} + value={endpointType} + onValueChange={handleEndpointTypeChange} + id='endpoint-type' + className='w-full min-w-0' + placeholder={t('Auto detect (default)')} + />

{t( 'Override the endpoint used for testing. Leave empty to auto detect.' diff --git a/web/src/features/channels/components/dialogs/codex-usage-dialog.tsx b/web/src/features/channels/components/dialogs/codex-usage-dialog.tsx index bc6d28e3d..80e8b5311 100644 --- a/web/src/features/channels/components/dialogs/codex-usage-dialog.tsx +++ b/web/src/features/channels/components/dialogs/codex-usage-dialog.tsx @@ -76,6 +76,7 @@ import { Skeleton } from '@/components/ui/skeleton' import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard' import dayjs from '@/lib/dayjs' import { formatDateTimeStr, formatTimestampToDate } from '@/lib/format' +import { createServerError } from '@/lib/server-error-message' import { cn } from '@/lib/utils' import { @@ -983,8 +984,9 @@ export function CodexUsageDialog({ try { const res = await getCodexResetCredits(channelId) if (!res.success) { - throw new Error( - res.message || t('Failed to fetch reset credit details') + throw createServerError( + res, + t('Failed to fetch reset credit details') ) } setResetCreditsResponse(res) @@ -1034,7 +1036,7 @@ export function CodexUsageDialog({ try { const res = await resetCodexUsage(channelId) if (!res.success) { - throw new Error(res.message || t('Failed to reset usage')) + throw createServerError(res, t('Failed to reset usage')) } const resetPayload = res.data as diff --git a/web/src/features/channels/components/dialogs/edit-tag-dialog.tsx b/web/src/features/channels/components/dialogs/edit-tag-dialog.tsx index 8077234e3..4d07fdad0 100644 --- a/web/src/features/channels/components/dialogs/edit-tag-dialog.tsx +++ b/web/src/features/channels/components/dialogs/edit-tag-dialog.tsx @@ -16,7 +16,6 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ -import { Combobox } from '@/components/ui/combobox' import { useQuery, useQueryClient } from '@tanstack/react-query' import { Loader2 } from 'lucide-react' import { useEffect, useState } from 'react' @@ -28,11 +27,13 @@ import { GroupBadge } from '@/components/group-badge' import { JsonCodeEditor } from '@/components/json-code-editor' import { StatusBadge } from '@/components/status-badge' import { Button } from '@/components/ui/button' +import { Combobox } from '@/components/ui/combobox' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { ScrollArea } from '@/components/ui/scroll-area' - import { Separator } from '@/components/ui/separator' +import { handleServerError } from '@/lib/handle-server-error' +import { requireServerSuccess } from '@/lib/server-error-message' import { editTagChannels, @@ -65,21 +66,24 @@ export function EditTagDialog({ open, onOpenChange }: EditTagDialogProps) { // Fetch tag models const { data: tagModelsData, isLoading: isLoadingTagModels } = useQuery({ queryKey: ['tag-models', currentTag], - queryFn: () => (currentTag ? getTagModels(currentTag) : null), + queryFn: async () => + requireServerSuccess( + await (currentTag ? getTagModels(currentTag) : null) + ), enabled: open && !!currentTag, }) // Fetch all available models const { data: allModelsData } = useQuery({ queryKey: ['all-models'], - queryFn: getAllModels, + queryFn: async () => requireServerSuccess(await getAllModels()), enabled: open, }) // Fetch groups const { data: groupsData } = useQuery({ queryKey: ['groups'], - queryFn: getGroups, + queryFn: async () => requireServerSuccess(await getGroups()), enabled: open, }) @@ -193,12 +197,10 @@ export function EditTagDialog({ open, onOpenChange }: EditTagDialogProps) { queryClient.invalidateQueries({ queryKey: channelsQueryKeys.lists() }) onOpenChange(false) } else { - toast.error(response.message || t('Failed to update tag')) + handleServerError(response, t('Failed to update tag')) } } catch (error: unknown) { - toast.error( - error instanceof Error ? error.message : t('Failed to update tag') - ) + handleServerError(error, t('Failed to update tag')) } finally { setIsSubmitting(false) } @@ -298,14 +300,19 @@ export function EditTagDialog({ open, onOpenChange }: EditTagDialogProps) {

({ value: model, label: model }))} - onValueChange={(value: string | null) => { - if (value !== null && !selectedModels.includes(value)) setSelectedModels([...selectedModels, value]) - }} - className='flex-1' - placeholder={t('Add from available models...')} - aria-label={t('Add from available models...')} -/> + options={availableModels.map((model) => ({ + value: model, + label: model, + }))} + onValueChange={(value: string | null) => { + if (value !== null && !selectedModels.includes(value)) { + setSelectedModels([...selectedModels, value]) + } + }} + className='flex-1' + placeholder={t('Add from available models...')} + aria-label={t('Add from available models...')} + />
diff --git a/web/src/features/channels/components/dialogs/fetch-models-dialog.tsx b/web/src/features/channels/components/dialogs/fetch-models-dialog.tsx index 03b39e2ce..568b481e7 100644 --- a/web/src/features/channels/components/dialogs/fetch-models-dialog.tsx +++ b/web/src/features/channels/components/dialogs/fetch-models-dialog.tsx @@ -38,6 +38,7 @@ import { TooltipContent, TooltipTrigger, } from '@/components/ui/tooltip' +import { handleServerError } from '@/lib/handle-server-error' import { fetchUpstreamModels, updateChannel } from '../../api' import { @@ -157,14 +158,12 @@ export function FetchModelsDialog({ setSelectedModels(existingModels) toast.success(t('Fetched {{count}} models', { count: list.length })) } else { - toast.error(response.message || t('Failed to fetch models')) + handleServerError(response, t('Failed to fetch models')) setFetchedModels([]) } } } catch (error: unknown) { - toast.error( - error instanceof Error ? error.message : t('Failed to fetch models') - ) + handleServerError(error, t('Failed to fetch models')) setFetchedModels([]) } finally { setIsFetching(false) @@ -193,12 +192,10 @@ export function FetchModelsDialog({ queryClient.invalidateQueries({ queryKey: channelsQueryKeys.lists() }) onOpenChange(false) } else { - toast.error(response.message || t('Failed to update models')) + handleServerError(response, t('Failed to update models')) } } catch (error: unknown) { - toast.error( - error instanceof Error ? error.message : t('Failed to update models') - ) + handleServerError(error, t('Failed to update models')) } finally { setIsSaving(false) } diff --git a/web/src/features/channels/components/dialogs/multi-key-manage-dialog.tsx b/web/src/features/channels/components/dialogs/multi-key-manage-dialog.tsx index 22f752149..0fd28bffe 100644 --- a/web/src/features/channels/components/dialogs/multi-key-manage-dialog.tsx +++ b/web/src/features/channels/components/dialogs/multi-key-manage-dialog.tsx @@ -41,6 +41,7 @@ import { ADMIN_PERMISSION_RESOURCES, hasPermission, } from '@/lib/admin-permissions' +import { handleServerError } from '@/lib/handle-server-error' import { useAuthStore } from '@/stores/auth-store' import { @@ -137,19 +138,17 @@ export function MultiKeyManageDialog({ setManualDisabledCount(response.data.manual_disabled_count || 0) setAutoDisabledCount(response.data.auto_disabled_count || 0) } else { - toast.error(response.message || t('Failed to load key status')) + handleServerError(response, t('Failed to load key status')) } } catch (error: unknown) { - toast.error( - error instanceof Error ? error.message : t('Failed to load key status') - ) + handleServerError(error, t('Failed to load key status')) } finally { setIsLoading(false) } } const handleStatusFilterChange = (value: string) => { - const newFilter = value === 'all' ? null : parseInt(value) + const newFilter = value === 'all' ? null : Number.parseInt(value) setStatusFilter(newFilter) setCurrentPage(1) loadKeyStatus(1, pageSize, newFilter) @@ -204,12 +203,10 @@ export function MultiKeyManageDialog({ loadKeyStatus(currentPage, pageSize) } } else { - toast.error(response?.message || t('Operation failed')) + handleServerError(response, t('Operation failed')) } } catch (error: unknown) { - toast.error( - error instanceof Error ? error.message : t('Operation failed') - ) + handleServerError(error, t('Operation failed')) } finally { setIsPerformingAction(false) setConfirmAction(null) @@ -294,12 +291,10 @@ export function MultiKeyManageDialog({ {/* Toolbar */}
setNewTag(e.target.value)} - disabled={isSaving} - /> -

- {t('Leave empty to disband the tag')} -

-
- - {/* Models */} -
- -