Desktop connection SaaS: config, billing, team support (#5768)

Co-authored-by: James Brunton <[email protected]>
Co-authored-by: James Brunton <[email protected]>
This commit is contained in:
ConnorYoh
2026-02-25 14:13:07 +00:00
committed by GitHub
co-authored by James Brunton James Brunton
parent 86072ec91a
commit 5c39acecd8
76 changed files with 6231 additions and 119 deletions
@@ -0,0 +1,12 @@
/**
* Core stub for QuickAccessBar footer extensions
* Desktop build overrides this with actual credit counter implementation
*/
interface QuickAccessBarFooterExtensionsProps {
className?: string;
}
export function QuickAccessBarFooterExtensions(_props: QuickAccessBarFooterExtensionsProps) {
return null;
}
@@ -0,0 +1,11 @@
interface CloudBadgeProps {
className?: string;
}
/**
* Stub component for cloud badge (desktop override provides real implementation)
* In web builds, this returns null since cloud routing is desktop-only
*/
export function CloudBadge(_props: CloudBadgeProps) {
return null; // Stub - does nothing in web builds
}
@@ -27,6 +27,7 @@ import {
getActiveNavButton,
} from '@app/components/shared/quickAccessBar/QuickAccessBar';
import { Z_INDEX_OVER_FULLSCREEN_SURFACE } from '@app/styles/zIndex';
import { QuickAccessBarFooterExtensions } from '@app/components/quickAccessBar/QuickAccessBarFooterExtensions';
const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
const { t } = useTranslation();
@@ -265,6 +266,8 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
{/* Spacer to push bottom buttons to bottom */}
<div className="spacer" />
<QuickAccessBarFooterExtensions className="quick-access-footer" />
{/* Bottom section */}
<Stack gap="lg" align="stretch">
{bottomButtons.map((buttonConfig, index) => {
@@ -0,0 +1,9 @@
/**
* Core stub for SaaS Teams Section
* Desktop layer provides the real implementation
* This component only appears in SaaS mode
*/
export function SaaSTeamsSection() {
// Core stub - return null (no team management in web builds)
return null;
}
@@ -0,0 +1,8 @@
/**
* Core stub for SaasPlanSection
* This component returns null in non-desktop builds
* The desktop layer shadows this with the real implementation
*/
export function SaasPlanSection() {
return null;
}
@@ -0,0 +1,13 @@
/**
* Core stub for Credit Exhausted Modal
* Desktop build overrides this with actual modal implementation
*/
interface CreditExhaustedModalProps {
opened: boolean;
onClose: () => void;
}
export function CreditExhaustedModal(_props: CreditExhaustedModalProps) {
return null;
}
@@ -0,0 +1,15 @@
/**
* Core stub for Insufficient Credits Modal
* Desktop build overrides this with actual modal implementation
*/
interface InsufficientCreditsModalProps {
opened: boolean;
onClose: () => void;
toolId?: string;
requiredCredits?: number;
}
export function InsufficientCreditsModal(_props: InsufficientCreditsModalProps) {
return null;
}
@@ -9,6 +9,7 @@ import { useFileSelection } from "@app/contexts/FileContext";
import { useFileState } from "@app/contexts/FileContext";
import { detectFileExtension } from "@app/utils/fileUtils";
import { usePreferences } from "@app/contexts/PreferencesContext";
import { useConversionCloudStatus } from "@app/hooks/useConversionCloudStatus";
import GroupedFormatDropdown from "@app/components/tools/convert/GroupedFormatDropdown";
import ConvertToImageSettings from "@app/components/tools/convert/ConvertToImageSettings";
import ConvertFromImageSettings from "@app/components/tools/convert/ConvertFromImageSettings";
@@ -63,7 +64,18 @@ const ConvertSettings = ({
const { endpointStatus } = useMultipleEndpointsEnabled(allEndpoints);
// Get comprehensive conversion status (availability + cloud routing)
const conversionStatus = useConversionCloudStatus();
const isConversionAvailable = (fromExt: string, toExt: string): boolean => {
const conversionKey = `${fromExt}-${toExt}`;
// In desktop SaaS mode, check combined availability (local OR SaaS)
if (conversionStatus.availability[conversionKey] !== undefined) {
return conversionStatus.availability[conversionKey];
}
// Fallback to local-only check (web mode or desktop non-SaaS mode)
const endpointKey = EXTENSION_TO_ENDPOINT[fromExt]?.[toExt];
if (!endpointKey) return false;
@@ -71,6 +83,10 @@ const ConvertSettings = ({
return isAvailable;
};
const doesConversionUseCloud = (fromExt: string, toExt: string): boolean => {
return conversionStatus.cloudStatus[`${fromExt}-${toExt}`] || false;
};
// Enhanced FROM options with endpoint availability
const enhancedFromOptions = useMemo(() => {
const baseOptions = FROM_FORMAT_OPTIONS.map(option => {
@@ -107,18 +123,20 @@ const ConvertSettings = ({
}
return filteredOptions;
}, [parameters.fromExtension, endpointStatus, preferences.hideUnavailableConversions]);
}, [parameters.fromExtension, endpointStatus, preferences.hideUnavailableConversions, conversionStatus]);
// Enhanced TO options with endpoint availability
// Enhanced TO options with endpoint availability and cloud status
const enhancedToOptions = useMemo(() => {
if (!parameters.fromExtension) return [];
const availableOptions = getAvailableToExtensions(parameters.fromExtension) || [];
const enhanced = availableOptions.map(option => {
const enabled = isConversionAvailable(parameters.fromExtension, option.value);
const usesCloud = doesConversionUseCloud(parameters.fromExtension, option.value);
return {
...option,
enabled
enabled,
usesCloud
};
});
@@ -128,7 +146,7 @@ const ConvertSettings = ({
}
return enhanced;
}, [parameters.fromExtension, endpointStatus, preferences.hideUnavailableConversions]);
}, [parameters.fromExtension, endpointStatus, preferences.hideUnavailableConversions, conversionStatus]);
const resetParametersToDefaults = () => {
onParameterChange('imageOptions', {
@@ -1,6 +1,7 @@
import { useState, useMemo } from "react";
import { Stack, Text, Group, Button, Box, Popover, UnstyledButton, useMantineTheme, useMantineColorScheme } from "@mantine/core";
import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown";
import CloudOutlinedIcon from "@mui/icons-material/CloudOutlined";
import { Z_INDEX_AUTOMATE_DROPDOWN } from "@app/styles/zIndex";
interface FormatOption {
@@ -8,6 +9,7 @@ interface FormatOption {
label: string;
group: string;
enabled?: boolean;
usesCloud?: boolean;
}
interface GroupedFormatDropdownProps {
@@ -145,10 +147,20 @@ const GroupedFormatDropdown = ({
fontSize: '0.75rem',
height: '2rem',
padding: '0 0.75rem',
flexShrink: 0
flexShrink: 0,
position: 'relative'
}}
>
{option.label}
{option.usesCloud && (
<CloudOutlinedIcon
style={{
fontSize: '0.625rem',
marginLeft: '0.25rem',
opacity: 0.7
}}
/>
)}
</Button>
))}
</Group>
@@ -1,7 +1,8 @@
import { Button } from '@mantine/core';
import { Button, Box } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { Tooltip } from '@app/components/shared/Tooltip';
import { useBackendHealth } from '@app/hooks/useBackendHealth';
import { CloudBadge } from '@app/components/shared/CloudBadge';
export interface OperationButtonProps {
onClick?: () => void;
@@ -14,6 +15,7 @@ export interface OperationButtonProps {
fullWidth?: boolean;
mt?: string;
type?: 'button' | 'submit' | 'reset';
showCloudBadge?: boolean;
'data-testid'?: string;
'data-tour'?: string;
}
@@ -29,6 +31,7 @@ const OperationButton = ({
fullWidth = false,
mt = 'md',
type = 'button',
showCloudBadge = false,
'data-testid': dataTestId,
'data-tour': dataTour
}: OperationButtonProps) => {
@@ -54,12 +57,17 @@ const OperationButton = ({
color={color}
data-testid={dataTestId}
data-tour={dataTour}
style={{ minHeight: '2.5rem' }}
style={{ minHeight: '2.5rem', position: 'relative' }}
>
{isLoading
? (loadingText || t("loading", "Loading..."))
: (submitText || t("submit", "Submit"))
}
{showCloudBadge && (
<Box style={{ position: 'absolute', top: 4, right: 4 }}>
<CloudBadge />
</Box>
)}
</Button>
);
@@ -38,6 +38,7 @@ export interface ExecuteButtonConfig {
isVisible?: boolean;
disabled?: boolean;
testId?: string;
showCloudBadge?: boolean;
}
export interface ReviewStepConfig<TParams = unknown> {
@@ -105,6 +106,7 @@ export function createToolFlow<TParams = unknown>(config: ToolFlowConfig<TParams
disabled={config.executeButton.disabled}
loadingText={config.executeButton.loadingText}
submitText={config.executeButton.text}
showCloudBadge={config.executeButton.showCloudBadge ?? config.review.operation.willUseCloud ?? false}
data-testid={config.executeButton.testId}
data-tour="run-button"
/>
@@ -14,6 +14,8 @@ import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
import { ToolId } from "@app/types/toolId";
import { getToolDisabledReason, getDisabledLabel } from "@app/components/tools/fullscreen/shared";
import { useAppConfig } from "@app/contexts/AppConfigContext";
import { CloudBadge } from "@app/components/shared/CloudBadge";
import { useToolCloudStatus } from "@app/hooks/useToolCloudStatus";
interface ToolButtonProps {
id: ToolId;
@@ -38,6 +40,10 @@ const ToolButton: React.FC<ToolButtonProps> = ({ id, tool, isSelected, onSelect,
const { getToolNavigation } = useToolNavigation();
const fav = isFavorite(id as ToolId);
// Check if this tool will route to SaaS backend (desktop only)
const endpointName = tool.endpoints?.[0];
const usesCloud = useToolCloudStatus(endpointName);
const handleClick = (id: ToolId) => {
if (isUnavailable) return;
if (tool.link) {
@@ -98,6 +104,9 @@ const ToolButton: React.FC<ToolButtonProps> = ({ id, tool, isSelected, onSelect,
{t('toolPanel.alpha', 'Alpha')}
</Badge>
)}
{usesCloud && !isUnavailable && (
<CloudBadge />
)}
</div>
{matchedSynonym && (
<span style={{
@@ -0,0 +1,76 @@
import { createContext, ReactNode } from 'react';
/**
* Core stub for SaaS Team Context
* Desktop layer provides the real implementation
*/
export interface TeamMember {
id: string;
username: string;
email: string;
role: string;
joinedAt: number;
}
export interface TeamInvitation {
id: string;
email: string;
invitedAt: number;
invitedBy: string;
}
export interface Team {
id: string;
name: string;
isPersonal: boolean;
createdAt: number;
}
export interface SaaSTeamContextValue {
currentTeam: Team | null;
teams: Team[];
teamMembers: TeamMember[];
teamInvitations: TeamInvitation[];
receivedInvitations: TeamInvitation[];
isTeamLeader: boolean;
isPersonalTeam: boolean;
loading: boolean;
inviteUser: (teamId: string, email: string) => Promise<void>;
acceptInvitation: (invitationId: string) => Promise<void>;
rejectInvitation: (invitationId: string) => Promise<void>;
cancelInvitation: (invitationId: string) => Promise<void>;
removeMember: (teamId: string, memberId: string) => Promise<void>;
leaveTeam: (teamId: string) => Promise<void>;
refreshTeams: () => Promise<void>;
}
const SaaSTeamContext = createContext<SaaSTeamContextValue | undefined>(undefined);
export function SaaSTeamProvider({ children }: { children: ReactNode }) {
// Core stub - no-op implementation
return children;
}
export function useSaaSTeam(): SaaSTeamContextValue {
// Core stub - return default values
return {
currentTeam: null,
teams: [],
teamMembers: [],
teamInvitations: [],
receivedInvitations: [],
isTeamLeader: false,
isPersonalTeam: true,
loading: false,
inviteUser: async () => {},
acceptInvitation: async () => {},
rejectInvitation: async () => {},
cancelInvitation: async () => {},
removeMember: async () => {},
leaveTeam: async () => {},
refreshTeams: async () => {},
};
}
export { SaaSTeamContext };
@@ -0,0 +1,10 @@
/**
* Core stub for SaasBillingContext.
* Returns null in web/core builds — desktop layer shadows this with the real implementation.
* See: frontend/src/desktop/contexts/SaasBillingContext.tsx
*/
export const useSaaSBilling = (): null => null;
export function SaasBillingProvider({ children }: { children: React.ReactNode }) {
return <>{children}</>;
}
@@ -1,10 +1,11 @@
import { useCallback } from 'react';
import { useCallback, useMemo } from 'react';
import apiClient from '@app/services/apiClient';
import { useTranslation } from 'react-i18next';
import { ConvertParameters, defaultParameters } from '@app/hooks/tools/convert/useConvertParameters';
import { createFileFromApiResponse } from '@app/utils/fileResponseUtils';
import { useToolOperation, ToolType, CustomProcessorResult } from '@app/hooks/tools/shared/useToolOperation';
import { getEndpointUrl, isImageFormat, isWebFormat, isOfficeFormat } from '@app/utils/convertUtils';
import { getEndpointUrl, getEndpointName, isImageFormat, isWebFormat, isOfficeFormat } from '@app/utils/convertUtils';
import { useToolCloudStatus } from '@app/hooks/useToolCloudStatus';
// Static function that can be used by both the hook and automation executor
export const shouldProcessFilesSeparately = (
@@ -195,9 +196,21 @@ export const convertOperationConfig = {
defaultParameters,
} as const;
export const useConvertOperation = () => {
export const useConvertOperation = (parameters?: ConvertParameters) => {
const { t } = useTranslation();
// Calculate current endpoint name for cloud detection
const currentEndpointName = useMemo(() => {
if (!parameters?.fromExtension || !parameters?.toExtension) return undefined;
// Map PDF/X to use PDF/A endpoint (same as in convertProcessor)
const actualToExtension = parameters.toExtension === 'pdfx' ? 'pdfa' : parameters.toExtension;
return getEndpointName(parameters.fromExtension, actualToExtension);
}, [parameters?.fromExtension, parameters?.toExtension]);
// Check if current conversion will use cloud
const willUseCloud = useToolCloudStatus(currentEndpointName);
const customConvertProcessor = useCallback(async (
parameters: ConvertParameters,
selectedFiles: File[]
@@ -205,7 +218,7 @@ export const useConvertOperation = () => {
return convertProcessor(parameters, selectedFiles);
}, []);
return useToolOperation<ConvertParameters>({
const operation = useToolOperation<ConvertParameters>({
...convertOperationConfig,
customProcessor: customConvertProcessor, // Use instance-specific processor for translation support
getErrorMessage: (error) => {
@@ -218,4 +231,10 @@ export const useConvertOperation = () => {
return t("convert.errorConversion", "An error occurred while converting the file.");
},
});
// Override willUseCloud with our calculated value
return {
...operation,
willUseCloud,
};
};
@@ -15,6 +15,8 @@ import { createNewStirlingFileStub } from '@app/types/fileContext';
import { ToolOperation } from '@app/types/file';
import { ToolId } from '@app/types/toolId';
import { ensureBackendReady } from '@app/services/backendReadinessGuard';
import { useWillUseCloud } from '@app/hooks/useWillUseCloud';
import { useCreditCheck } from '@app/hooks/useCreditCheck';
// Re-export for backwards compatibility
export type { ProcessingProgress, ResponseHandler };
@@ -147,6 +149,7 @@ export interface ToolOperationHook<TParams = void> {
status: string;
errorMessage: string | null;
progress: ProcessingProgress | null;
willUseCloud?: boolean;
// Actions
executeOperation: (params: TParams, selectedFiles: StirlingFile[]) => Promise<void>;
@@ -183,6 +186,16 @@ export const useToolOperation = <TParams>(
const { processFiles, cancelOperation: cancelApiCalls } = useToolApiCalls<TParams>();
const { generateThumbnails, createDownloadInfo, cleanupBlobUrls, extractZipFiles } = useToolResources();
const { checkCredits } = useCreditCheck(config.operationType);
// Determine endpoint for cloud usage check
const endpointString = config.toolType !== ToolType.custom && config.endpoint
? (typeof config.endpoint === 'function'
? (config.defaultParameters ? config.endpoint(config.defaultParameters) : undefined)
: config.endpoint)
: undefined;
const willUseCloud = useWillUseCloud(endpointString);
// Track last operation for undo functionality
const lastOperationRef = useRef<{
inputFiles: File[];
@@ -217,7 +230,24 @@ export const useToolOperation = <TParams>(
return;
}
const backendReady = await ensureBackendReady();
// Get endpoint (static or dynamic) for backend readiness check
const endpoint = config.customProcessor
? undefined // Custom processors may not have endpoints
: typeof config.endpoint === 'function'
? config.endpoint(params)
: config.endpoint;
// Credit check for cloud operations (desktop SaaS mode only, no-op in web builds)
if (willUseCloud && endpoint) {
const creditError = await checkCredits();
if (creditError !== null) {
actions.setError(creditError);
return;
}
}
// Backend readiness check (will skip for SaaS-routed endpoints)
const backendReady = await ensureBackendReady(endpoint);
if (!backendReady) {
actions.setError(t('backendHealth.offline', 'Embedded backend is offline. Please try again shortly.'));
return;
@@ -507,7 +537,7 @@ export const useToolOperation = <TParams>(
actions.setLoading(false);
actions.setProgress(null);
}
}, [t, config, actions, addFiles, consumeFiles, processFiles, generateThumbnails, createDownloadInfo, cleanupBlobUrls, extractZipFiles]);
}, [t, config, actions, addFiles, consumeFiles, processFiles, generateThumbnails, createDownloadInfo, cleanupBlobUrls, extractZipFiles, willUseCloud, checkCredits]);
const cancelOperation = useCallback(() => {
cancelApiCalls();
@@ -591,6 +621,7 @@ export const useToolOperation = <TParams>(
status: state.status,
errorMessage: state.errorMessage,
progress: state.progress,
willUseCloud,
// Actions
executeOperation,
@@ -0,0 +1,21 @@
/**
* Comprehensive conversion status data
*/
export interface ConversionStatus {
availability: Record<string, boolean>; // Available on local OR SaaS?
cloudStatus: Record<string, boolean>; // Will use cloud?
localOnly: Record<string, boolean>; // Available ONLY locally (not on SaaS)?
}
/**
* Core stub for conversion cloud status checking
* Desktop layer provides the real implementation
* In web builds, always returns empty objects (no cloud routing)
*/
export function useConversionCloudStatus(): ConversionStatus {
return {
availability: {},
cloudStatus: {},
localOnly: {},
}; // Core stub - web builds don't use cloud
}
+10
View File
@@ -0,0 +1,10 @@
/**
* Core stub for credit checking before cloud operations
* Desktop layer shadows this with the real implementation
* In web builds, always allows the operation (no credit system)
*/
export function useCreditCheck(_operationType?: string) {
return {
checkCredits: async (): Promise<string | null> => null, // null = allowed
};
}
@@ -0,0 +1,8 @@
/**
* Core stub for tool cloud status checking
* Desktop layer provides the real implementation
* In web builds, always returns false (no cloud routing)
*/
export function useToolCloudStatus(_endpointName?: string): boolean {
return false; // Core stub - web builds don't use cloud
}
@@ -0,0 +1,8 @@
/**
* Core stub for cloud usage detection
* Desktop layer provides the real implementation
* In web builds, always returns false since there's no cloud routing
*/
export function useWillUseCloud(_endpoint?: string): boolean {
return false; // Core stub - web builds don't use cloud
}
@@ -1,7 +1,8 @@
/**
* Default backend readiness guard (web builds do not need to wait for
* anything outside the browser, so we always report ready).
* @param _endpoint - Optional endpoint path (not used in web builds)
*/
export async function ensureBackendReady(): Promise<boolean> {
export async function ensureBackendReady(_endpoint?: string): Promise<boolean> {
return true;
}
+5 -76
View File
@@ -1,83 +1,9 @@
// frontend/src/services/httpErrorHandler.ts
import axios from 'axios';
import { alert } from '@app/components/toast';
import { broadcastErroredFiles, extractErrorFileIds, normalizeAxiosErrorData } from '@app/services/errorUtils';
import { showSpecialErrorToast } from '@app/services/specialErrorToasts';
const FRIENDLY_FALLBACK = 'There was an error processing your request.';
const MAX_TOAST_BODY_CHARS = 400; // avoid massive, unreadable toasts
function clampText(s: string, max = MAX_TOAST_BODY_CHARS): string {
return s && s.length > max ? `${s.slice(0, max)}` : s;
}
function isUnhelpfulMessage(msg: string | null | undefined): boolean {
const s = (msg || '').trim();
if (!s) return true;
// Common unhelpful payloads we see
if (s === '{}' || s === '[]') return true;
if (/^request failed/i.test(s)) return true;
if (/^network error/i.test(s)) return true;
if (/^[45]\d\d\b/.test(s)) return true; // "500 Server Error" etc.
return false;
}
function titleForStatus(status?: number): string {
if (!status) return 'Network error';
if (status >= 500) return 'Server error';
if (status >= 400) return 'Request error';
return 'Request failed';
}
function extractAxiosErrorMessage(error: any): { title: string; body: string } {
if (axios.isAxiosError(error)) {
const status = error.response?.status;
const _statusText = error.response?.statusText || '';
let parsed: any = undefined;
const raw = error.response?.data;
if (typeof raw === 'string') {
try { parsed = JSON.parse(raw); } catch { /* keep as string */ }
} else {
parsed = raw;
}
const extractIds = (): string[] | undefined => {
if (Array.isArray(parsed?.errorFileIds)) return parsed.errorFileIds as string[];
const rawText = typeof raw === 'string' ? raw : '';
const uuidMatches = rawText.match(/[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}/g);
return uuidMatches && uuidMatches.length > 0 ? Array.from(new Set(uuidMatches)) : undefined;
};
const body = ((): string => {
const data = parsed;
if (!data) return typeof raw === 'string' ? raw : '';
const ids = extractIds();
if (ids && ids.length > 0) return `Failed files: ${ids.join(', ')}`;
if (data?.message) return data.message as string;
if (typeof raw === 'string') return raw;
try { return JSON.stringify(data); } catch { return ''; }
})();
const ids = extractIds();
const title = titleForStatus(status);
if (ids && ids.length > 0) {
return { title, body: 'Process failed due to invalid/corrupted file(s)' };
}
if (status === 422) {
const fallbackMsg = 'Process failed due to invalid/corrupted file(s)';
const bodyMsg = isUnhelpfulMessage(body) ? fallbackMsg : body;
return { title, body: bodyMsg };
}
const bodyMsg = isUnhelpfulMessage(body) ? FRIENDLY_FALLBACK : body;
return { title, body: bodyMsg };
}
try {
const msg = (error?.message || String(error)) as string;
return { title: 'Network error', body: isUnhelpfulMessage(msg) ? FRIENDLY_FALLBACK : msg };
} catch (e) {
// ignore extraction errors
console.debug('extractAxiosErrorMessage', e);
return { title: 'Network error', body: FRIENDLY_FALLBACK };
}
}
import { handleSaaSError } from '@app/services/saasErrorInterceptor';
import { clampText, extractAxiosErrorMessage } from '@app/services/httpErrorUtils';
// Module-scoped state to reduce global variable usage
const recentSpecialByEndpoint: Record<string, number> = {};
@@ -126,6 +52,9 @@ export async function handleHttpError(error: any): Promise<boolean> {
console.debug('[httpErrorHandler] Suppressing 401 on auth page:', pathname);
return true;
}
if (handleSaaSError(error)) return true;
// Compute title/body (friendly) from the error object
const { title, body } = extractAxiosErrorMessage(error);
@@ -0,0 +1,76 @@
import axios from 'axios';
const FRIENDLY_FALLBACK = 'There was an error processing your request.';
const MAX_TOAST_BODY_CHARS = 400; // avoid massive, unreadable toasts
export function clampText(s: string, max = MAX_TOAST_BODY_CHARS): string {
return s && s.length > max ? `${s.slice(0, max)}` : s;
}
function isUnhelpfulMessage(msg: string | null | undefined): boolean {
const s = (msg || '').trim();
if (!s) return true;
// Common unhelpful payloads we see
if (s === '{}' || s === '[]') return true;
if (/^request failed/i.test(s)) return true;
if (/^network error/i.test(s)) return true;
if (/^[45]\d\d\b/.test(s)) return true; // "500 Server Error" etc.
return false;
}
function titleForStatus(status?: number): string {
if (!status) return 'Network error';
if (status >= 500) return 'Server error';
if (status >= 400) return 'Request error';
return 'Request failed';
}
export function extractAxiosErrorMessage(error: any): { title: string; body: string } {
if (axios.isAxiosError(error)) {
const status = error.response?.status;
const _statusText = error.response?.statusText || '';
let parsed: any = undefined;
const raw = error.response?.data;
if (typeof raw === 'string') {
try { parsed = JSON.parse(raw); } catch { /* keep as string */ }
} else {
parsed = raw;
}
const extractIds = (): string[] | undefined => {
if (Array.isArray(parsed?.errorFileIds)) return parsed.errorFileIds as string[];
const rawText = typeof raw === 'string' ? raw : '';
const uuidMatches = rawText.match(/[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}/g);
return uuidMatches && uuidMatches.length > 0 ? Array.from(new Set(uuidMatches)) : undefined;
};
const body = ((): string => {
const data = parsed;
if (!data) return typeof raw === 'string' ? raw : '';
const ids = extractIds();
if (ids && ids.length > 0) return `Failed files: ${ids.join(', ')}`;
if (data?.message) return data.message as string;
if (typeof raw === 'string') return raw;
try { return JSON.stringify(data); } catch { return ''; }
})();
const ids = extractIds();
const title = titleForStatus(status);
if (ids && ids.length > 0) {
return { title, body: 'Process failed due to invalid/corrupted file(s)' };
}
if (status === 422) {
const fallbackMsg = 'Process failed due to invalid/corrupted file(s)';
const bodyMsg = isUnhelpfulMessage(body) ? fallbackMsg : body;
return { title, body: bodyMsg };
}
const bodyMsg = isUnhelpfulMessage(body) ? FRIENDLY_FALLBACK : body;
return { title, body: bodyMsg };
}
try {
const msg = (error?.message || String(error)) as string;
return { title: 'Network error', body: isUnhelpfulMessage(msg) ? FRIENDLY_FALLBACK : msg };
} catch (e) {
// ignore extraction errors
console.debug('extractAxiosErrorMessage', e);
return { title: 'Network error', body: FRIENDLY_FALLBACK };
}
}
@@ -0,0 +1,8 @@
/**
* Core stub for SaaS backend error interception.
* Desktop layer shadows this with the real implementation.
* In web builds there are no SaaS requests, so this always returns false.
*/
export function handleSaaSError(_error: unknown): boolean {
return false;
}
+1 -1
View File
@@ -19,7 +19,7 @@ const Convert = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
const scrollContainerRef = useRef<HTMLDivElement>(null);
const convertParams = useConvertParameters();
const convertOperation = useConvertOperation();
const convertOperation = useConvertOperation(convertParams.parameters);
const { enabled: endpointEnabled, loading: endpointLoading } = useEndpointEnabled(convertParams.getEndpointName());