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:
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user