Press n or j to go to the next uncovered block, b, p or k for the previous block.
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 | 3x 3x 3x 3x 3x 3x 3x 3x 2x 2x 2x 1x 1x 2x 1x 7x 7x 7x 3x 6x 6x 6x 2x 2x 2x 1x 1x 1x 2x 4x 4x 3x 2x 2x 2x 2x 2x 1x 3x 2x 1x 1x 3x 1x 1x 1x 3x 1x 1x 1x 3x 1x 1x 1x 1x 3x 1x 3x 1x 3x 1x 3x 1x 1x 3x 1x 1x 3x 1x 1x 3x 1x 1x 3x 1x 1x 1x 1x 1x 3x 1x | // 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 } from '../types';
import { logger } from './logger.client';
import { 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}`);
console.error(
`[aiApiClient] uploadAndProcessFlyer: Uploading file '${file.name}' with checksum '${checksum}'`,
);
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) {
logger.debug({ err: e }, 'Failed to parse error response as JSON, falling back to text');
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> => {
console.error(`[aiApiClient] getJobStatus: Fetching status for job '${jobId}'`);
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();
Eif (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(
{ err: e, status: response.status },
'getJobStatus received a non-JSON error response.',
);
}
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 });
};
|