Some checks failed
Deploy to Test Environment / deploy-to-test (push) Has been cancelled
314 lines
11 KiB
TypeScript
314 lines
11 KiB
TypeScript
// src/services/aiApiClient.ts
|
|
/**
|
|
* @file This file acts as a client-side API wrapper for all AI-related functionalities.
|
|
* It communicates with the application's own backend endpoints, which then securely
|
|
* call the Google AI services. This ensures no API keys are exposed on the client.
|
|
*/
|
|
import type {
|
|
FlyerItem,
|
|
Store,
|
|
MasterGroceryItem,
|
|
ProcessingStage,
|
|
GroundedResponse,
|
|
} from '../types';
|
|
import { logger } from './logger.client';
|
|
import { apiFetch, authedGet, authedPost, authedPostForm } from './apiClient';
|
|
|
|
/**
|
|
* Uploads a flyer file to the backend to be processed asynchronously.
|
|
* This is the first step in the new background processing flow.
|
|
* @param file The flyer file (PDF or image).
|
|
* @param checksum The SHA-256 checksum of the file.
|
|
* @param tokenOverride Optional token for testing.
|
|
* @returns A promise that resolves to the API response, which should contain a `jobId`.
|
|
*/
|
|
export const uploadAndProcessFlyer = async (
|
|
file: File,
|
|
checksum: string,
|
|
tokenOverride?: string,
|
|
): Promise<{ jobId: string }> => {
|
|
const formData = new FormData();
|
|
formData.append('flyerFile', file);
|
|
formData.append('checksum', checksum);
|
|
|
|
logger.info(`[aiApiClient] Starting background processing for file: ${file.name}`);
|
|
|
|
const response = await authedPostForm('/ai/upload-and-process', formData, { tokenOverride });
|
|
|
|
if (!response.ok) {
|
|
let errorBody;
|
|
// Clone the response so we can read the body twice (once as JSON, and as text on failure).
|
|
const clonedResponse = response.clone();
|
|
try {
|
|
errorBody = await response.json();
|
|
} catch (e) {
|
|
errorBody = { message: await clonedResponse.text() };
|
|
}
|
|
// Throw a structured error so the component can inspect the status and body
|
|
throw { status: response.status, body: errorBody };
|
|
}
|
|
|
|
return response.json();
|
|
};
|
|
|
|
// Define the expected shape of the job status response
|
|
export interface JobStatus {
|
|
id: string;
|
|
state: 'completed' | 'failed' | 'active' | 'waiting' | 'delayed' | 'paused';
|
|
progress: {
|
|
stages?: ProcessingStage[];
|
|
estimatedTimeRemaining?: number;
|
|
// The structured error payload from the backend worker
|
|
errorCode?: string;
|
|
message?: string;
|
|
} | null;
|
|
returnValue: {
|
|
flyerId?: number;
|
|
} | null;
|
|
failedReason: string | null; // The raw error string from BullMQ
|
|
}
|
|
|
|
/**
|
|
* Custom error class for job failures to make `catch` blocks more specific.
|
|
* This allows the UI to easily distinguish between a job failure and a network error.
|
|
*/
|
|
export class JobFailedError extends Error {
|
|
public errorCode: string;
|
|
|
|
constructor(message: string, errorCode: string) {
|
|
super(message);
|
|
this.name = 'JobFailedError';
|
|
this.errorCode = errorCode;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Fetches the status of a background processing job.
|
|
* This is the second step in the new background processing flow.
|
|
* @param jobId The ID of the job to check.
|
|
* @param tokenOverride Optional token for testing.
|
|
* @returns A promise that resolves to the parsed job status object.
|
|
* @throws A `JobFailedError` if the job has failed, or a generic `Error` for other issues.
|
|
*/
|
|
export const getJobStatus = async (
|
|
jobId: string,
|
|
tokenOverride?: string,
|
|
): Promise<JobStatus> => {
|
|
const response = await authedGet(`/ai/jobs/${jobId}/status`, { tokenOverride });
|
|
|
|
// Handle non-OK responses first, as they might not have a JSON body.
|
|
if (!response.ok) {
|
|
let errorMessage = `API Error: ${response.status} ${response.statusText}`;
|
|
try {
|
|
// Try to get a more specific message from the body.
|
|
const errorData = await response.json();
|
|
if (errorData.message) {
|
|
errorMessage = errorData.message;
|
|
}
|
|
} catch (e) {
|
|
// The body was not JSON, which is fine for a server error page.
|
|
// The default message is sufficient.
|
|
logger.warn('getJobStatus received a non-JSON error response.', { status: response.status });
|
|
}
|
|
throw new Error(errorMessage);
|
|
}
|
|
|
|
// If we get here, the response is OK (2xx). Now parse the body.
|
|
try {
|
|
const statusData: JobStatus = await response.json();
|
|
|
|
// If the job itself has failed, we should treat this as an error condition
|
|
// for the polling logic by rejecting the promise. This will stop the polling loop.
|
|
if (statusData.state === 'failed') {
|
|
// The structured error payload is in the 'progress' object.
|
|
const progress = statusData.progress;
|
|
const userMessage =
|
|
progress?.message || statusData.failedReason || 'Job failed with an unknown error.';
|
|
const errorCode = progress?.errorCode || 'UNKNOWN_ERROR';
|
|
|
|
logger.error(`Job ${jobId} failed with code: ${errorCode}, message: ${userMessage}`);
|
|
|
|
// Throw a custom, structured error so the frontend can react to the errorCode.
|
|
throw new JobFailedError(userMessage, errorCode);
|
|
}
|
|
|
|
return statusData;
|
|
} catch (error) {
|
|
// If it's the specific error we threw, just re-throw it.
|
|
if (error instanceof JobFailedError) {
|
|
throw error;
|
|
}
|
|
// This now primarily catches JSON parsing errors on an OK response, which is unexpected.
|
|
logger.error('getJobStatus failed to parse a successful API response.', { error });
|
|
throw new Error('Failed to parse job status from a successful API response.');
|
|
}
|
|
};
|
|
|
|
export const isImageAFlyer = (
|
|
imageFile: File,
|
|
tokenOverride?: string,
|
|
): Promise<Response> => {
|
|
const formData = new FormData();
|
|
formData.append('image', imageFile);
|
|
|
|
// Use apiFetchWithAuth for FormData to let the browser set the correct Content-Type.
|
|
// The URL must be relative, as the helper constructs the full path.
|
|
return authedPostForm('/ai/check-flyer', formData, { tokenOverride });
|
|
};
|
|
|
|
export const extractAddressFromImage = (
|
|
imageFile: File,
|
|
tokenOverride?: string,
|
|
): Promise<Response> => {
|
|
const formData = new FormData();
|
|
formData.append('image', imageFile);
|
|
|
|
return authedPostForm('/ai/extract-address', formData, { tokenOverride });
|
|
};
|
|
|
|
export const extractLogoFromImage = (
|
|
imageFiles: File[],
|
|
tokenOverride?: string,
|
|
): Promise<Response> => {
|
|
const formData = new FormData();
|
|
imageFiles.forEach((file) => {
|
|
formData.append('images', file);
|
|
});
|
|
|
|
return authedPostForm('/ai/extract-logo', formData, { tokenOverride });
|
|
};
|
|
|
|
export const getQuickInsights = (
|
|
items: Partial<FlyerItem>[],
|
|
signal?: AbortSignal,
|
|
tokenOverride?: string,
|
|
): Promise<Response> => {
|
|
return authedPost('/ai/quick-insights', { items }, { tokenOverride, signal });
|
|
};
|
|
|
|
export const getDeepDiveAnalysis = (
|
|
items: Partial<FlyerItem>[],
|
|
signal?: AbortSignal,
|
|
tokenOverride?: string,
|
|
): Promise<Response> => {
|
|
return authedPost('/ai/deep-dive', { items }, { tokenOverride, signal });
|
|
};
|
|
|
|
export const searchWeb = (
|
|
query: string,
|
|
signal?: AbortSignal,
|
|
tokenOverride?: string,
|
|
): Promise<Response> => {
|
|
return authedPost('/ai/search-web', { query }, { tokenOverride, signal });
|
|
};
|
|
|
|
// ============================================================================
|
|
// STUBS FOR FUTURE AI FEATURES
|
|
// ============================================================================
|
|
|
|
export const planTripWithMaps = async (
|
|
items: FlyerItem[],
|
|
store: Store | undefined,
|
|
userLocation: GeolocationCoordinates,
|
|
signal?: AbortSignal,
|
|
tokenOverride?: string,
|
|
): Promise<Response> => {
|
|
logger.debug('Stub: planTripWithMaps called with location:', { userLocation });
|
|
return authedPost('/ai/plan-trip', { items, store, userLocation }, { signal, tokenOverride });
|
|
};
|
|
|
|
/**
|
|
* [STUB] Generates an image based on a text prompt using the Imagen model.
|
|
* @param prompt A description of the image to generate (e.g., a meal plan).
|
|
* @returns A base64-encoded string of the generated PNG image.
|
|
*/
|
|
export const generateImageFromText = (
|
|
prompt: string,
|
|
signal?: AbortSignal,
|
|
tokenOverride?: string,
|
|
): Promise<Response> => {
|
|
logger.debug('Stub: generateImageFromText called with prompt:', { prompt });
|
|
return authedPost('/ai/generate-image', { prompt }, { tokenOverride, signal });
|
|
};
|
|
|
|
/**
|
|
* [STUB] Converts a string of text into speech audio data.
|
|
* @param text The text to be spoken.
|
|
* @returns A base64-encoded string of the raw audio data.
|
|
*/
|
|
export const generateSpeechFromText = (
|
|
text: string,
|
|
signal?: AbortSignal,
|
|
tokenOverride?: string,
|
|
): Promise<Response> => {
|
|
logger.debug('Stub: generateSpeechFromText called with text:', { text });
|
|
return authedPost('/ai/generate-speech', { text }, { tokenOverride, signal });
|
|
};
|
|
|
|
/**
|
|
* [STUB] Initiates a real-time voice conversation session using the Live API.
|
|
* This function is more complex and would require a WebSocket connection proxied
|
|
* through the backend. For now, this remains a conceptual client-side function.
|
|
* A full implementation would involve a separate WebSocket client.
|
|
* @param callbacks An object containing onopen, onmessage, onerror, and onclose handlers.
|
|
* @returns A promise that resolves to the live session object.
|
|
*/
|
|
export const startVoiceSession = (callbacks: {
|
|
onopen?: () => void;
|
|
onmessage: (message: import('@google/genai').LiveServerMessage) => void;
|
|
onerror?: (error: ErrorEvent) => void;
|
|
onclose?: () => void;
|
|
}): Promise<unknown> => {
|
|
logger.debug('Stub: startVoiceSession called.', { callbacks });
|
|
// In a real implementation, this would connect to a WebSocket endpoint on your server,
|
|
// which would then proxy the connection to the Google AI Live API.
|
|
// This is a placeholder and will not function.
|
|
throw new Error(
|
|
'Voice session feature is not fully implemented and requires a backend WebSocket proxy.',
|
|
);
|
|
};
|
|
|
|
/*
|
|
The following functions are server-side only and have been moved to `aiService.server.ts`.
|
|
This file should not contain any server-side logic or direct use of `fs` or `process.env`.
|
|
|
|
- extractItemsFromReceiptImage
|
|
- extractCoreDataFromFlyerImage
|
|
*/
|
|
|
|
/**
|
|
* Sends a cropped area of an image to the backend for targeted text extraction.
|
|
* @param imageFile The original image file.
|
|
* @param cropArea The { x, y, width, height } of the area to scan.
|
|
* @param extractionType The type of data to look for ('store_name', 'dates', etc.).
|
|
* @param tokenOverride Optional token for testing.
|
|
* @returns A promise that resolves to the API response containing the extracted text.
|
|
*/
|
|
export const rescanImageArea = (
|
|
imageFile: File,
|
|
cropArea: { x: number; y: number; width: number; height: number },
|
|
extractionType: 'store_name' | 'dates' | 'item_details',
|
|
tokenOverride?: string,
|
|
): Promise<Response> => {
|
|
const formData = new FormData();
|
|
formData.append('image', imageFile);
|
|
formData.append('cropArea', JSON.stringify(cropArea));
|
|
formData.append('extractionType', extractionType);
|
|
|
|
return authedPostForm('/ai/rescan-area', formData, { tokenOverride });
|
|
};
|
|
|
|
/**
|
|
* Sends a user's watched items to the AI backend for price comparison.
|
|
* @param watchedItems An array of the user's watched master grocery items.
|
|
* @returns A promise that resolves to the raw `Response` object from the API.
|
|
*/
|
|
export const compareWatchedItemPrices = (
|
|
watchedItems: MasterGroceryItem[],
|
|
signal?: AbortSignal,
|
|
): Promise<Response> => {
|
|
// Use the apiFetch wrapper for consistency with other API calls in this file.
|
|
// This centralizes token handling and base URL logic.
|
|
return authedPost('/ai/compare-prices', { items: watchedItems }, { signal });
|
|
};
|