diff --git a/src/PowderCoating.Web/wwwroot/js/item-wizard.js b/src/PowderCoating.Web/wwwroot/js/item-wizard.js index 333ebb6..eb2a6cc 100644 --- a/src/PowderCoating.Web/wwwroot/js/item-wizard.js +++ b/src/PowderCoating.Web/wwwroot/js/item-wizard.js @@ -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) {