diff --git a/web/default/src/features/channels/api.ts b/web/default/src/features/channels/api.ts index 1b3f4035892..6e92519f98b 100644 --- a/web/default/src/features/channels/api.ts +++ b/web/default/src/features/channels/api.ts @@ -16,8 +16,7 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ -import type { AxiosRequestConfig } from 'axios' -import { api } from '@/lib/api' +import { api, type ApiRequestConfig } from '@/lib/api' import { getGroups as getUserGroups } from '@/features/users/api' import type { AddChannelRequest, @@ -39,11 +38,13 @@ import type { TagOperationParams, } from './types' -// Extended API config types -interface ExtendedApiConfig extends AxiosRequestConfig { - skipBusinessError?: boolean - disableDuplicate?: boolean -} +const channelActionConfig = ( + config: ApiRequestConfig = {} +): ApiRequestConfig => ({ + ...config, + skipBusinessError: true, + skipErrorHandler: true, +}) export type CodexOAuthStartResponse = { success: boolean @@ -125,7 +126,7 @@ export async function getChannel(id: number): Promise { export async function createChannel( data: AddChannelRequest ): Promise<{ success: boolean; message?: string }> { - const res = await api.post('/api/channel', data) + const res = await api.post('/api/channel', data, channelActionConfig()) return res.data } @@ -136,7 +137,11 @@ export async function updateChannel( id: number, data: Partial ): Promise<{ success: boolean; message?: string; data?: Channel }> { - const res = await api.put('/api/channel/', { id, ...data }) + const res = await api.put( + '/api/channel/', + { id, ...data }, + channelActionConfig() + ) return res.data } @@ -146,7 +151,7 @@ export async function updateChannel( export async function deleteChannel( id: number ): Promise<{ success: boolean; message?: string }> { - const res = await api.delete(`/api/channel/${id}`) + const res = await api.delete(`/api/channel/${id}`, channelActionConfig()) return res.data } @@ -156,7 +161,7 @@ export async function deleteChannel( export async function batchDeleteChannels( data: BatchDeleteParams ): Promise<{ success: boolean; message?: string; data?: number }> { - const res = await api.post('/api/channel/batch', data) + const res = await api.post('/api/channel/batch', data, channelActionConfig()) return res.data } @@ -166,7 +171,11 @@ export async function batchDeleteChannels( export async function batchSetChannelTag( data: BatchSetTagParams ): Promise<{ success: boolean; message?: string; data?: number }> { - const res = await api.post('/api/channel/batch/tag', data) + const res = await api.post( + '/api/channel/batch/tag', + data, + channelActionConfig() + ) return res.data } @@ -181,7 +190,10 @@ export async function testChannel( id: number, params?: { model?: string; endpoint_type?: string; stream?: boolean } ): Promise { - const res = await api.get(`/api/channel/test/${id}`, { params }) + const res = await api.get( + `/api/channel/test/${id}`, + channelActionConfig({ params }) + ) return res.data } @@ -191,7 +203,10 @@ export async function testChannel( export async function updateChannelBalance( id: number ): Promise { - const res = await api.get(`/api/channel/update_balance/${id}`) + const res = await api.get( + `/api/channel/update_balance/${id}`, + channelActionConfig() + ) return res.data } @@ -201,7 +216,10 @@ export async function updateChannelBalance( export async function fetchUpstreamModels( id: number ): Promise { - const res = await api.get(`/api/channel/fetch_models/${id}`) + const res = await api.get( + `/api/channel/fetch_models/${id}`, + channelActionConfig() + ) return res.data } @@ -212,7 +230,11 @@ export async function copyChannel( id: number, params: CopyChannelParams = {} ): Promise { - const res = await api.post(`/api/channel/copy/${id}`, null, { params }) + const res = await api.post( + `/api/channel/copy/${id}`, + null, + channelActionConfig({ params }) + ) return res.data } @@ -224,7 +246,11 @@ export async function fixChannelAbilities(): Promise<{ message?: string data?: { success: number; fails: number } }> { - const res = await api.post('/api/channel/fix') + const res = await api.post( + '/api/channel/fix', + undefined, + channelActionConfig() + ) return res.data } @@ -236,7 +262,7 @@ export async function deleteDisabledChannels(): Promise<{ message?: string data?: number }> { - const res = await api.delete('/api/channel/disabled') + const res = await api.delete('/api/channel/disabled', channelActionConfig()) return res.data } @@ -248,7 +274,11 @@ export async function getChannelKey( code?: string ): Promise<{ success: boolean; message?: string; data?: { key: string } }> { const payload = code ? { code } : undefined - const res = await api.post(`/api/channel/${id}/key`, payload) + const res = await api.post( + `/api/channel/${id}/key`, + payload, + channelActionConfig() + ) return res.data } @@ -257,19 +287,21 @@ export async function getChannelKey( // ============================================================================ export async function startCodexOAuth(): Promise { - const config: ExtendedApiConfig = { skipBusinessError: true } - const res = await api.post('/api/channel/codex/oauth/start', {}, config) + const res = await api.post( + '/api/channel/codex/oauth/start', + {}, + channelActionConfig() + ) return res.data } export async function completeCodexOAuth( input: string ): Promise { - const config: ExtendedApiConfig = { skipBusinessError: true } const res = await api.post( '/api/channel/codex/oauth/complete', { input }, - config + channelActionConfig() ) return res.data } @@ -277,11 +309,10 @@ export async function completeCodexOAuth( export async function refreshCodexCredential( channelId: number ): Promise { - const config: ExtendedApiConfig = { skipBusinessError: true } const res = await api.post( `/api/channel/${channelId}/codex/refresh`, {}, - config + channelActionConfig() ) return res.data } @@ -289,11 +320,10 @@ export async function refreshCodexCredential( export async function getCodexUsage( channelId: number ): Promise { - const config: ExtendedApiConfig = { - skipBusinessError: true, - disableDuplicate: true, - } - const res = await api.get(`/api/channel/${channelId}/codex/usage`, config) + const res = await api.get( + `/api/channel/${channelId}/codex/usage`, + channelActionConfig({ disableDuplicate: true }) + ) return res.data } @@ -307,7 +337,11 @@ export async function getCodexUsage( export async function manageMultiKeys( params: MultiKeyManageParams ): Promise { - const res = await api.post('/api/channel/multi_key/manage', params) + const res = await api.post( + '/api/channel/multi_key/manage', + params, + channelActionConfig() + ) return res.data } @@ -417,7 +451,11 @@ export async function deleteDisabledMultiKeys( export async function enableTagChannels( tag: string ): Promise<{ success: boolean; message?: string }> { - const res = await api.post('/api/channel/tag/enabled', { tag }) + const res = await api.post( + '/api/channel/tag/enabled', + { tag }, + channelActionConfig() + ) return res.data } @@ -427,7 +465,11 @@ export async function enableTagChannels( export async function disableTagChannels( tag: string ): Promise<{ success: boolean; message?: string }> { - const res = await api.post('/api/channel/tag/disabled', { tag }) + const res = await api.post( + '/api/channel/tag/disabled', + { tag }, + channelActionConfig() + ) return res.data } @@ -437,7 +479,7 @@ export async function disableTagChannels( export async function editTagChannels( params: TagOperationParams ): Promise<{ success: boolean; message?: string }> { - const res = await api.put('/api/channel/tag', params) + const res = await api.put('/api/channel/tag', params, channelActionConfig()) return res.data } @@ -463,7 +505,11 @@ export async function fetchModels(data: { type: number key: string }): Promise { - const res = await api.post('/api/channel/fetch_models', data) + const res = await api.post( + '/api/channel/fetch_models', + data, + channelActionConfig() + ) return res.data } @@ -474,7 +520,10 @@ export async function deleteOllamaModel(params: { channel_id: number model_name: string }): Promise<{ success: boolean; message?: string }> { - const res = await api.delete('/api/channel/ollama/delete', { data: params }) + const res = await api.delete( + '/api/channel/ollama/delete', + channelActionConfig({ data: params }) + ) return res.data } @@ -485,7 +534,7 @@ export async function testAllChannels(): Promise<{ success: boolean message?: string }> { - const res = await api.get('/api/channel/test') + const res = await api.get('/api/channel/test', channelActionConfig()) return res.data } @@ -496,7 +545,10 @@ export async function updateAllChannelsBalance(): Promise<{ success: boolean message?: string }> { - const res = await api.get('/api/channel/update_balance') + const res = await api.get( + '/api/channel/update_balance', + channelActionConfig() + ) return res.data } diff --git a/web/default/src/features/channels/components/dialogs/multi-key-manage-dialog.tsx b/web/default/src/features/channels/components/dialogs/multi-key-manage-dialog.tsx index d87b7fa858d..33b1f542ae4 100644 --- a/web/default/src/features/channels/components/dialogs/multi-key-manage-dialog.tsx +++ b/web/default/src/features/channels/components/dialogs/multi-key-manage-dialog.tsx @@ -135,6 +135,8 @@ export function MultiKeyManageDialog({ setEnabledCount(response.data.enabled_count || 0) 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')) } } catch (error: unknown) { toast.error( diff --git a/web/default/src/features/channels/components/dialogs/ollama-models-dialog.tsx b/web/default/src/features/channels/components/dialogs/ollama-models-dialog.tsx index cee1b57fbb7..c883aa9c4a7 100644 --- a/web/default/src/features/channels/components/dialogs/ollama-models-dialog.tsx +++ b/web/default/src/features/channels/components/dialogs/ollama-models-dialog.tsx @@ -211,14 +211,22 @@ export function OllamaModelsDialog({ ? Array.from(new Set(selected)) : Array.from(new Set([...existingModels, ...selected])) - const res = await updateChannel(currentRow.id, { models: next.join(',') }) - if (res.success) { - toast.success( - mode === 'replace' - ? t('Models updated successfully') - : t('Models appended successfully') + try { + const res = await updateChannel(currentRow.id, { models: next.join(',') }) + if (res.success) { + toast.success( + mode === 'replace' + ? t('Models updated successfully') + : t('Models appended successfully') + ) + queryClient.invalidateQueries({ queryKey: channelsQueryKeys.lists() }) + } else { + toast.error(res.message || t('Failed to update models')) + } + } catch (err: unknown) { + toast.error( + err instanceof Error ? err.message : t('Failed to update models') ) - queryClient.invalidateQueries({ queryKey: channelsQueryKeys.lists() }) } } diff --git a/web/default/src/features/channels/components/drawers/channel-mutate-drawer.tsx b/web/default/src/features/channels/components/drawers/channel-mutate-drawer.tsx index 39a6e1527b5..55949de537b 100644 --- a/web/default/src/features/channels/components/drawers/channel-mutate-drawer.tsx +++ b/web/default/src/features/channels/components/drawers/channel-mutate-drawer.tsx @@ -1033,6 +1033,8 @@ export function ChannelMutateDrawer({ if (response.success) { toast.success(t(SUCCESS_MESSAGES.UPDATED)) handleSuccess() + } else { + throw new Error(response.message || t(ERROR_MESSAGES.UPDATE_FAILED)) } } else { // Create new channel(s) @@ -1041,6 +1043,8 @@ export function ChannelMutateDrawer({ if (response.success) { toast.success(t(SUCCESS_MESSAGES.CREATED)) handleSuccess() + } else { + throw new Error(response.message || t(ERROR_MESSAGES.CREATE_FAILED)) } } } catch (error: unknown) { @@ -3381,7 +3385,9 @@ export function ChannelMutateDrawer({ redirectSourceModels={redirectModelKeyList} customFetcher={!isEditing ? createModeFetcher : undefined} existingModelsOverride={ - !isEditing ? parseModelsString(form.getValues('models') || '') : undefined + !isEditing + ? parseModelsString(form.getValues('models') || '') + : undefined } /> diff --git a/web/default/src/features/channels/hooks/use-channel-upstream-updates.ts b/web/default/src/features/channels/hooks/use-channel-upstream-updates.ts index f643b72f4a1..e28c21d9048 100644 --- a/web/default/src/features/channels/hooks/use-channel-upstream-updates.ts +++ b/web/default/src/features/channels/hooks/use-channel-upstream-updates.ts @@ -19,9 +19,14 @@ For commercial licensing, please contact support@quantumnous.com import { useRef, useState, useCallback } from 'react' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' -import { api } from '@/lib/api' +import { api, type ApiRequestConfig } from '@/lib/api' import { normalizeModelList } from '../lib/upstream-update-utils' +const upstreamUpdateRequestConfig = { + skipBusinessError: true, + skipErrorHandler: true, +} satisfies ApiRequestConfig + function getManualIgnoredModelCount(settings: unknown): number { let parsed: Record | null = null if (settings && typeof settings === 'object') @@ -117,7 +122,7 @@ export function useChannelUpstreamUpdates(refresh: () => Promise) { ignore_models: ignoreModels, remove_models: normalizeModelList(selectedRemove), }, - { skipErrorHandler: true } as Record + upstreamUpdateRequestConfig ) const { success, message, data } = res.data || {} if (!success) { @@ -162,7 +167,7 @@ export function useChannelUpstreamUpdates(refresh: () => Promise) { const res = await api.post( '/api/channel/upstream_updates/apply_all', {}, - { skipErrorHandler: true } as Record + upstreamUpdateRequestConfig ) const { success, message, data } = res.data || {} if (!success) { @@ -206,7 +211,7 @@ export function useChannelUpstreamUpdates(refresh: () => Promise) { const res = await api.post( '/api/channel/upstream_updates/detect', { id: ch.id }, - { skipErrorHandler: true } as Record + upstreamUpdateRequestConfig ) const { success, message, data } = res.data || {} if (!success) { @@ -244,7 +249,7 @@ export function useChannelUpstreamUpdates(refresh: () => Promise) { const res = await api.post( '/api/channel/upstream_updates/detect_all', {}, - { skipErrorHandler: true } as Record + upstreamUpdateRequestConfig ) const { success, message, data } = res.data || {} if (!success) { diff --git a/web/default/src/features/channels/lib/channel-actions.ts b/web/default/src/features/channels/lib/channel-actions.ts index 884f81fede3..bdbddbbebb1 100644 --- a/web/default/src/features/channels/lib/channel-actions.ts +++ b/web/default/src/features/channels/lib/channel-actions.ts @@ -70,6 +70,8 @@ export async function handleEnableChannel( toast.success(i18next.t(SUCCESS_MESSAGES.ENABLED)) queryClient?.invalidateQueries({ queryKey: channelsQueryKeys.lists() }) onSuccess?.() + } else { + toast.error(response.message || i18next.t(ERROR_MESSAGES.UPDATE_FAILED)) } } catch (_error) { toast.error(i18next.t(ERROR_MESSAGES.UPDATE_FAILED)) @@ -92,6 +94,8 @@ export async function handleDisableChannel( toast.success(i18next.t(SUCCESS_MESSAGES.DISABLED)) queryClient?.invalidateQueries({ queryKey: channelsQueryKeys.lists() }) onSuccess?.() + } else { + toast.error(response.message || i18next.t(ERROR_MESSAGES.UPDATE_FAILED)) } } catch (_error) { toast.error(i18next.t(ERROR_MESSAGES.UPDATE_FAILED)) @@ -128,6 +132,8 @@ export async function handleDeleteChannel( toast.success(i18next.t(SUCCESS_MESSAGES.DELETED)) queryClient?.invalidateQueries({ queryKey: channelsQueryKeys.lists() }) onSuccess?.() + } else { + toast.error(response.message || i18next.t(ERROR_MESSAGES.DELETE_FAILED)) } } catch (_error) { toast.error(i18next.t(ERROR_MESSAGES.DELETE_FAILED)) @@ -257,6 +263,8 @@ export async function handleCopyChannel( toast.success(i18next.t(SUCCESS_MESSAGES.COPIED)) queryClient?.invalidateQueries({ queryKey: channelsQueryKeys.lists() }) onSuccess?.(response.data.id) + } else { + toast.error(response.message || i18next.t('Failed to copy channel')) } } catch (_error) { toast.error(i18next.t('Failed to copy channel')) @@ -325,6 +333,8 @@ export async function handleBatchDelete( ) queryClient?.invalidateQueries({ queryKey: channelsQueryKeys.lists() }) onSuccess?.(response.data || ids.length) + } else { + toast.error(response.message || i18next.t(ERROR_MESSAGES.DELETE_FAILED)) } } catch (_error) { toast.error(i18next.t(ERROR_MESSAGES.DELETE_FAILED)) @@ -351,8 +361,10 @@ export async function handleBatchEnable( ) const results = await Promise.allSettled(promises) - const successCount = results.filter((r) => r.status === 'fulfilled').length - const failCount = results.filter((r) => r.status === 'rejected').length + const successCount = results.filter( + (r) => r.status === 'fulfilled' && r.value.success + ).length + const failCount = results.length - successCount if (successCount > 0) { toast.success( @@ -392,8 +404,10 @@ export async function handleBatchDisable( ) const results = await Promise.allSettled(promises) - const successCount = results.filter((r) => r.status === 'fulfilled').length - const failCount = results.filter((r) => r.status === 'rejected').length + const successCount = results.filter( + (r) => r.status === 'fulfilled' && r.value.success + ).length + const failCount = results.length - successCount if (successCount > 0) { toast.success( @@ -435,6 +449,8 @@ export async function handleBatchSetTag( toast.success(i18next.t(SUCCESS_MESSAGES.TAG_SET)) queryClient?.invalidateQueries({ queryKey: channelsQueryKeys.lists() }) onSuccess?.() + } else { + toast.error(response.message || i18next.t('Failed to set tag')) } } catch (_error) { toast.error(i18next.t('Failed to set tag')) @@ -461,6 +477,10 @@ export async function handleEnableTagChannels( ) queryClient?.invalidateQueries({ queryKey: channelsQueryKeys.lists() }) onSuccess?.() + } else { + toast.error( + response.message || i18next.t('Failed to enable tag channels') + ) } } catch (_error) { toast.error(i18next.t('Failed to enable tag channels')) @@ -483,6 +503,10 @@ export async function handleDisableTagChannels( ) queryClient?.invalidateQueries({ queryKey: channelsQueryKeys.lists() }) onSuccess?.() + } else { + toast.error( + response.message || i18next.t('Failed to disable tag channels') + ) } } catch (_error) { toast.error(i18next.t('Failed to disable tag channels')) @@ -510,6 +534,10 @@ export async function handleDeleteAllDisabled( ) queryClient?.invalidateQueries({ queryKey: channelsQueryKeys.lists() }) onSuccess?.(response.data || 0) + } else { + toast.error( + response.message || i18next.t('Failed to delete disabled channels') + ) } } catch (_error) { toast.error(i18next.t('Failed to delete disabled channels')) @@ -534,6 +562,8 @@ export async function handleFixAbilities( ) queryClient?.invalidateQueries({ queryKey: channelsQueryKeys.lists() }) onSuccess?.(response.data) + } else { + toast.error(response.message || i18next.t('Failed to fix abilities')) } } catch (_error) { toast.error(i18next.t('Failed to fix abilities')) diff --git a/web/default/src/lib/api.ts b/web/default/src/lib/api.ts index b3bf17eb19d..f6cc88057c5 100644 --- a/web/default/src/lib/api.ts +++ b/web/default/src/lib/api.ts @@ -16,11 +16,21 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ -import axios from 'axios' -import i18next from 'i18next' +import axios, { type AxiosRequestConfig } from 'axios' +import { t } from 'i18next' import { toast } from 'sonner' import { useAuthStore } from '@/stores/auth-store' +declare module 'axios' { + export interface AxiosRequestConfig { + skipBusinessError?: boolean + skipErrorHandler?: boolean + disableDuplicate?: boolean + } +} + +export type ApiRequestConfig = AxiosRequestConfig + // ============================================================================ // Axios Instance Configuration // ============================================================================ @@ -46,14 +56,11 @@ export const api = axios.create({ const inFlightGet = new Map>() const originalGet = api.get.bind(api) -api.get = ((url: string, config = {}) => { - const disableDuplicate = (config as unknown as Record) - ?.disableDuplicate +api.get = ((url: string, config: ApiRequestConfig = {}) => { + const disableDuplicate = config.disableDuplicate if (disableDuplicate) return originalGet(url, config) - const params = (config as unknown as Record)?.params - ? JSON.stringify((config as unknown as Record).params) - : '{}' + const params = config.params ? JSON.stringify(config.params) : '{}' const key = `${url}?${params}` // Return existing in-flight request if available @@ -72,8 +79,7 @@ api.get = ((url: string, config = {}) => { // Handle business logic errors and HTTP errors globally api.interceptors.response.use( (response) => { - const skipBusiness = (response.config as unknown as Record) - ?.skipBusinessError + const skipBusiness = response.config.skipBusinessError // Unified business response format: { success, message, data } if ( @@ -84,7 +90,7 @@ api.interceptors.response.use( ) { if (!response.data.success) { // Show error toast for business failures - const msg = response.data.message || 'Request failed' + const msg = response.data.message || t('Request failed') toast.error(msg) } } @@ -92,23 +98,23 @@ api.interceptors.response.use( }, (error) => { const skip = error?.config?.skipErrorHandler - if (!skip) { - const status = error?.response?.status - - if (status === 401) { - // Unauthorized: clear auth state and show toast - toast.error(i18next.t('Session expired!')) - try { - useAuthStore.getState().auth.reset() - } catch { - /* empty */ - } - } else { - // Other errors: show error message from response or default - const msg = - error?.response?.data?.message || error?.message || 'Request error' - toast.error(msg) + const status = error?.response?.status + + if (status === 401) { + try { + useAuthStore.getState().auth.reset() + } catch { + /* empty */ + } + + if (!skip) { + toast.error(t('Session expired!')) } + } else if (!skip) { + // Other errors: show error message from response or default + const msg = + error?.response?.data?.message || error?.message || t('Request failed') + toast.error(msg) } return Promise.reject(error) } @@ -175,7 +181,7 @@ export async function getSelf() { const res = await api.get('/api/user/self', { // Avoid global 401 toast during guards/preloads skipErrorHandler: true, - } as Record) + }) return res.data }