Add AI overload retry with model fallback and consolidate wizard errors

Anthropic returns overloaded_error (HTTP 529) during high-demand periods.
Previously this failed immediately with a generic error. Now the service
retries Sonnet once after 5s, then falls back to Haiku (a separate
capacity pool) after another 3s before giving up. If all three attempts
are overloaded the user sees a clear "high demand" message rather than a
generic error. Non-overload errors still log at Error level.

Also consolidated AI wizard error display in item-wizard.js: photo upload
failures were using browser alert() while analyze failures used the inline
red alert bar. All errors now go through aiShowError() so they always
appear consistently as the red bar below the Analyze button. Removed the
alert() fallback from aiShowError() itself.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-06 12:27:27 -04:00
parent a8fb56e8ec
commit 74414c6c71
2 changed files with 107 additions and 23 deletions
+60 -21
View File
@@ -1184,10 +1184,10 @@ async function aiUploadFile(file) {
aiRefreshPhotoList();
document.getElementById('ai_photoError')?.classList.add('d-none');
} else {
alert('Upload failed: ' + (result.error || 'Unknown error'));
aiShowError('Upload failed: ' + (result.error || 'Unknown error'));
}
} catch (err) {
alert('Upload error: ' + err.message);
aiShowError('Upload error: ' + err.message);
}
}
@@ -1454,24 +1454,49 @@ function aiShowError(message) {
el.textContent = message;
el.classList.remove('d-none');
el.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
} else {
// Fallback if element not found
alert('AI Error: ' + message);
}
}
// Step 3: Coating layers
function renderStep3Html() {
const isSandblastOnly = !!wz.data.sandblastOnly;
return `
<p class="text-muted small mb-3">
<i class="bi bi-info-circle me-1"></i>
Add one or more coating layers. The first coat uses 100% of the labor estimate;
each additional coat adds 30%.
</p>
<div id="coatsListContainer"></div>
<button type="button" class="btn btn-outline-success btn-sm mt-2" onclick="addCoatRow()">
<i class="bi bi-plus-circle me-1"></i>Add Coating Layer
</button>`;
<div class="form-check form-switch border rounded py-2 px-3 mb-3 bg-light">
<input class="form-check-input" type="checkbox" id="sandblastOnlyToggle"
${isSandblastOnly ? 'checked' : ''} onchange="onSandblastOnlyToggle()">
<label class="form-check-label" for="sandblastOnlyToggle">
<strong>Sandblast / Prep Only</strong>
<span class="text-muted fw-normal ms-2 small">— no powder coating applied</span>
</label>
</div>
<div id="coatingSectionWrap"${isSandblastOnly ? ' class="d-none"' : ''}>
<p class="text-muted small mb-3">
<i class="bi bi-info-circle me-1"></i>
Add one or more coating layers. The first coat uses 100% of the labor estimate;
each additional coat adds 30%.
</p>
<div id="coatsListContainer"></div>
<button type="button" class="btn btn-outline-success btn-sm mt-2" onclick="addCoatRow()">
<i class="bi bi-plus-circle me-1"></i>Add Coating Layer
</button>
</div>
${isSandblastOnly ? `<div class="text-center text-muted py-3">
<i class="bi bi-tools fs-3 d-block mb-2 opacity-50"></i>
No powder coating — no oven or powder costs will be applied.
</div>` : ''}`;
}
function onSandblastOnlyToggle() {
const checked = document.getElementById('sandblastOnlyToggle')?.checked;
wz.data.sandblastOnly = checked;
if (checked) {
wz.data.coats = [];
// AI price was estimated with coating in mind — clear it so pricing recalculates from prep labor
if (wz.itemType === 'ai') {
wz.data.manualUnitPrice = null;
}
}
renderStep(3);
}
function renderCoatsList() {
@@ -1893,8 +1918,9 @@ function renderStep4Html() {
</div>`;
}
const isSandblastOnly = !!wz.data.sandblastOnly;
const isCatalog = wz.itemType === 'product';
const isAi = wz.itemType === 'ai';
const isAi = wz.itemType === 'ai' && !isSandblastOnly;
const includePrepCost = wz.data.includePrepCost ?? !isCatalog; // default ON for calculated, OFF for catalog
const current = wz.data.prepServices || [];
@@ -1917,6 +1943,12 @@ function renderStep4Html() {
Select the services below for shop floor reference — they will <strong>not</strong> add to the item price.
</div>` : '';
const sandblastBanner = isSandblastOnly ? `
<div class="alert alert-warning py-2 mb-3">
<i class="bi bi-tools me-1"></i>
<strong>Sandblast / Prep Only:</strong> estimated minutes will be billed as labor — no powder or oven costs.
</div>` : '';
const blastOptions = blastSetupData.length > 0
? blastSetupData.map(s => `<option value="${s.id}" ${s.isDefault ? 'selected' : ''}>${escHtml(s.name)}</option>`).join('')
: '';
@@ -1960,7 +1992,7 @@ function renderStep4Html() {
? ''
: `<div class="text-muted small mb-3">Check each prep step needed and enter an estimated time. Labor cost is added to this item's total.</div>`;
return `${catalogBanner}${aiBanner}${hint}${rows}`;
return `${catalogBanner}${aiBanner}${sandblastBanner}${hint}${rows}`;
}
function onPrepIncludeCostToggle() {
@@ -2274,14 +2306,17 @@ function preFillStep2() {
function buildItemFromWizard() {
const d = wz.data;
const isAi = wz.itemType === 'ai';
const isSandblastOnly = !!d.sandblastOnly;
// Sandblast-only AI items lose the AI pricing flag — the AI price included coating costs
// that no longer apply, so the server prices from prep labor instead.
const isAi = wz.itemType === 'ai' && !isSandblastOnly;
return {
description: d.description || null,
quantity: d.quantity || 1,
surfaceAreaSqFt: d.surfaceAreaSqFt || 0,
estimatedMinutes: d.estimatedMinutes || 0,
catalogItemId: d.catalogItemId || null,
manualUnitPrice: d.manualUnitPrice ?? null,
manualUnitPrice: isAi ? (d.manualUnitPrice ?? null) : (d.isGenericItem || d.isSalesItem ? (d.manualUnitPrice ?? null) : null),
powderCostOverride: d.powderCostOverride ?? null,
isGenericItem: !!d.isGenericItem,
isLaborItem: !!d.isLaborItem,
@@ -2296,8 +2331,9 @@ function buildItemFromWizard() {
prepServices: d.prepServices || [],
includePrepCost: d.includePrepCost ?? (wz.itemType !== 'product'),
complexity: d.complexity || 'Simple',
aiPhotoTempIds: isAi ? (d.aiPhotoTempIds || []) : [],
aiPhotoFileNames: isAi ? (d.aiPhotoFileNames || []) : [],
// Keep AI photos even for sandblast-only so they get promoted to permanent storage
aiPhotoTempIds: wz.itemType === 'ai' ? (d.aiPhotoTempIds || []) : [],
aiPhotoFileNames: wz.itemType === 'ai' ? (d.aiPhotoFileNames || []) : [],
aiTags: isAi ? ((d.aiTags || []).join ? (d.aiTags || []).join(',') : d.aiTags) || null : null,
aiPredictionId: isAi ? (d.aiPredictionId ?? null) : null
};
@@ -2336,8 +2372,11 @@ function buildCardHtml(item, i) {
: { label: 'Custom', cls: 'success', icon: 'bi-rulers' };
const coatCount = item.coats?.length || 0;
const coatBadge = (coatCount > 0)
const isPrepOnly = coatCount === 0 && !item.isGenericItem && !item.isLaborItem && !item.isSalesItem && !item.catalogItemId;
const coatBadge = coatCount > 0
? `<span class="badge bg-secondary ms-1">${coatCount} coat${coatCount > 1 ? 's' : ''}</span>`
: isPrepOnly
? `<span class="badge bg-warning text-dark ms-1" title="No powder coating"><i class="bi bi-tools me-1"></i>Prep Only</span>`
: '';
const complexityBadge = (!item.isGenericItem && !item.isLaborItem && !item.catalogItemId && item.complexity && item.complexity !== 'Simple')