Fix AI quote reliability on mobile: compress photos + add fetch timeout

- Compress photos client-side before uploading (1200px max, JPEG 85%):
  full-res phone photos (5-15 MB) → ~150-250 KB, dramatically reducing
  upload time on slow mobile connections and Anthropic processing time
- Add 120s AbortController to both AiAnalyzeItem fetch calls so a stalled
  mobile connection produces a clear 'timed out' error instead of spinning forever
- After 30s show 'Still analyzing… this can take a minute on mobile' to
  reassure users the request is in progress
- Reset loading text on retry so the slow-connection hint doesn't persist

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-13 21:22:32 -04:00
parent 46b950baf2
commit f1d7054b3e
@@ -1166,16 +1166,50 @@ function aiHandleDrop(event) {
Array.from(event.dataTransfer.files).forEach(aiUploadFile);
}
// Resize + recompress an image file before upload so phone photos (5-15 MB)
// don't saturate mobile upload bandwidth or slow down Anthropic processing.
// Max 1200px on the long edge, JPEG at 85% quality — ~150-250 KB typical output.
// Non-image files and GIFs are returned unchanged.
async function aiCompressImage(file, maxPx = 1200, quality = 0.85) {
if (!file.type.startsWith('image/') || file.type === 'image/gif') return file;
return new Promise(resolve => {
const reader = new FileReader();
reader.onload = e => {
const img = new Image();
img.onload = () => {
const scale = Math.min(1, maxPx / Math.max(img.width, img.height));
const w = Math.round(img.width * scale);
const h = Math.round(img.height * scale);
const canvas = document.createElement('canvas');
canvas.width = w;
canvas.height = h;
canvas.getContext('2d').drawImage(img, 0, 0, w, h);
canvas.toBlob(blob => {
if (!blob || blob.size >= file.size) { resolve(file); return; }
resolve(new File([blob], file.name.replace(/\.[^.]+$/, '.jpg'), { type: 'image/jpeg' }));
}, 'image/jpeg', quality);
};
img.onerror = () => resolve(file);
img.src = e.target.result;
};
reader.onerror = () => resolve(file);
reader.readAsDataURL(file);
});
}
async function aiUploadFile(file) {
// Read as data: URL — blob: URLs are blocked by CSP; data: is explicitly allowed
// Compress before uploading — full-res phone photos slow upload + Anthropic API
const compressed = await aiCompressImage(file);
// Read compressed bytes for the thumbnail preview (blob: URLs blocked by CSP)
const previewUrl = await new Promise(resolve => {
const reader = new FileReader();
reader.onload = e => resolve(e.target.result);
reader.onerror = () => resolve('');
reader.readAsDataURL(file);
reader.readAsDataURL(compressed);
});
const formData = new FormData();
formData.append('file', file);
formData.append('file', compressed);
formData.append('__RequestVerificationToken',
document.querySelector('input[name="__RequestVerificationToken"]')?.value || '');
@@ -1278,15 +1312,27 @@ async function aiAnalyze() {
};
const analyzeUrl = pageMeta.aiAnalyzeUrl || '/Quotes/AiAnalyzeItem';
const controller = new AbortController();
// Abort after 120 s — server-side Anthropic timeout is 60 s per attempt with retries;
// 120 s gives room for one retry plus network round-trip on a slow mobile connection.
const hardTimeout = setTimeout(() => controller.abort(), 120_000);
// After 30 s without a response, update the spinner text so the user knows it's working.
const slowWarning = setTimeout(() => {
const t = document.getElementById('ai_loadingText');
if (t) t.textContent = 'Still analyzing… this can take a minute on mobile connections.';
}, 30_000);
try {
const resp = await fetch(analyzeUrl, {
method: 'POST',
signal: controller.signal,
headers: {
'Content-Type': 'application/json',
'RequestVerificationToken': document.querySelector('input[name="__RequestVerificationToken"]')?.value || ''
},
body: JSON.stringify(payload)
});
clearTimeout(hardTimeout);
clearTimeout(slowWarning);
if (!resp.ok) {
if (resp.status === 401 || resp.status === 302 || resp.redirected) {
throw new Error('Your session has expired. Please refresh the page and sign in again.');
@@ -1301,9 +1347,15 @@ async function aiAnalyze() {
const result = await resp.json();
aiHandleResult(result);
} catch (err) {
clearTimeout(hardTimeout);
clearTimeout(slowWarning);
console.error('AI analyze error:', err);
aiSetLoading(false);
aiShowError(err.message);
if (err.name === 'AbortError') {
aiShowError('The request timed out — your connection may be slow. Please try again.');
} else {
aiShowError(err.message);
}
}
}
@@ -1340,15 +1392,24 @@ async function aiSendFollowup() {
};
const analyzeUrl = pageMeta.aiAnalyzeUrl || '/Quotes/AiAnalyzeItem';
const controller2 = new AbortController();
const hardTimeout2 = setTimeout(() => controller2.abort(), 120_000);
const slowWarning2 = setTimeout(() => {
const t = document.getElementById('ai_loadingText');
if (t) t.textContent = 'Still analyzing… this can take a minute on mobile connections.';
}, 30_000);
try {
const resp = await fetch(analyzeUrl, {
method: 'POST',
signal: controller2.signal,
headers: {
'Content-Type': 'application/json',
'RequestVerificationToken': document.querySelector('input[name="__RequestVerificationToken"]')?.value || ''
},
body: JSON.stringify(payload)
});
clearTimeout(hardTimeout2);
clearTimeout(slowWarning2);
if (!resp.ok) {
if (resp.status === 401 || resp.status === 302 || resp.redirected) {
throw new Error('Your session has expired. Please refresh the page and sign in again.');
@@ -1362,9 +1423,15 @@ async function aiSendFollowup() {
const result = await resp.json();
aiHandleResult(result);
} catch (err) {
clearTimeout(hardTimeout2);
clearTimeout(slowWarning2);
console.error('AI follow-up error:', err);
aiSetLoading(false);
aiShowError(err.message);
if (err.name === 'AbortError') {
aiShowError('The request timed out — your connection may be slow. Please try again.');
} else {
aiShowError(err.message);
}
}
}
@@ -1445,12 +1512,14 @@ function aiRemoveTag(tag) {
}
function aiSetLoading(isLoading) {
const btn = document.getElementById('ai_analyzeBtn');
const btn = document.getElementById('ai_analyzeBtn');
const spinner = document.getElementById('ai_loadingSpinner');
const text = document.getElementById('ai_loadingText');
const text = document.getElementById('ai_loadingText');
if (btn) btn.disabled = isLoading;
spinner?.classList.toggle('d-none', !isLoading);
text?.classList.toggle('d-none', !isLoading);
// Reset text so a retry after the slow-connection warning shows the default message
if (!isLoading && text) text.textContent = 'Analyzing photos, please wait…';
}
function aiShowError(message) {