AI product identification with Gemini + dashboard layout fix

AI Identification:
- Rewrite analyzeWithAI() to use Gemini API for real image analysis
- Auto-start analysis immediately after taking photo (no manual button)
- Gemini identifies product name, brand, category from photo
- Reverse search on Open Food Facts to find matching barcoded products
- User can select a match to import full product data with barcode
- Or save product directly without barcode
- New API endpoint: gemini_identify with OFF reverse search

Dashboard:
- Move 🚫 Scaduti section to TOP of dashboard
- Show only top 4 soonest expiring items below
- Limit API query to 4 results
This commit is contained in:
dadaloop82
2026-03-10 12:20:57 +00:00
parent b548f2fe66
commit eb46f44eba
4 changed files with 400 additions and 79 deletions
+62
View File
@@ -1725,3 +1725,65 @@ body {
margin-bottom: 12px;
line-height: 1.5;
}
/* ===== AI IDENTIFICATION RESULTS ===== */
.ai-identified-card {
background: var(--bg-light);
border-radius: var(--radius-sm);
padding: 12px;
margin-top: 8px;
}
.ai-matches-list {
display: flex;
flex-direction: column;
gap: 8px;
margin-top: 8px;
}
.ai-match-item {
display: flex;
align-items: center;
gap: 10px;
padding: 10px;
background: var(--bg-card);
border: 2px solid var(--bg-light);
border-radius: var(--radius-sm);
cursor: pointer;
transition: border-color 0.2s, background 0.2s;
}
.ai-match-item:active {
border-color: var(--primary);
background: var(--bg-light);
}
.ai-match-img {
width: 48px;
height: 48px;
object-fit: contain;
border-radius: var(--radius-sm);
flex-shrink: 0;
background: #fff;
}
.ai-match-info {
flex: 1;
min-width: 0;
font-size: 0.9rem;
line-height: 1.4;
}
.ai-match-info strong {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ai-match-barcode {
font-size: 0.7rem;
color: var(--text-muted);
font-family: monospace;
flex-shrink: 0;
}
+160 -69
View File
@@ -1669,14 +1669,12 @@ async function initAICamera() {
const captureDiv = document.getElementById('ai-capture');
const previewDiv = document.getElementById('ai-preview');
const captureBtn = document.getElementById('ai-capture-btn');
const analyzeBtn = document.getElementById('ai-analyze-btn');
const retakeBtn = document.getElementById('ai-retake-btn');
const resultDiv = document.getElementById('ai-result');
captureDiv.style.display = 'block';
previewDiv.style.display = 'none';
captureBtn.style.display = 'block';
analyzeBtn.style.display = 'none';
retakeBtn.style.display = 'none';
resultDiv.style.display = 'none';
@@ -1718,8 +1716,10 @@ function takePhotoForAI() {
document.getElementById('ai-capture').style.display = 'none';
document.getElementById('ai-preview').style.display = 'block';
document.getElementById('ai-capture-btn').style.display = 'none';
document.getElementById('ai-analyze-btn').style.display = 'block';
document.getElementById('ai-retake-btn').style.display = 'block';
// Immediately start analysis
analyzeWithAI();
}
function retakePhotoAI() {
@@ -1730,82 +1730,173 @@ function retakePhotoAI() {
async function analyzeWithAI() {
const resultDiv = document.getElementById('ai-result');
resultDiv.style.display = 'block';
resultDiv.innerHTML = '<p>🤖 Analisi in corso...</p><div class="loading-spinner" style="margin:12px auto"></div>';
resultDiv.innerHTML = '<div style="text-align:center;padding:20px"><div class="loading-spinner" style="margin:0 auto 12px"></div><p>🤖 Identifico il prodotto...</p></div>';
const canvas = document.getElementById('ai-canvas');
const imageData = canvas.toDataURL('image/jpeg', 0.7);
// We'll use a free approach: analyze image colors and shapes locally
// and try to identify using image analysis heuristics
const ctx = canvas.getContext('2d');
const imgData = ctx.getImageData(0, 0, canvas.width, canvas.height);
// Simple color analysis to guess product type
let r = 0, g = 0, b = 0;
const pixels = imgData.data;
const count = pixels.length / 4;
for (let i = 0; i < pixels.length; i += 16) { // sample every 4th pixel
r += pixels[i];
g += pixels[i + 1];
b += pixels[i + 2];
const base64 = canvas.toDataURL('image/jpeg', 0.7).split(',')[1];
try {
const result = await api('gemini_identify', {}, 'POST', { image: base64 });
if (!result.success) {
if (result.error === 'no_api_key') {
resultDiv.innerHTML = `<p style="color:var(--warning)">⚠️ Chiave API Gemini non configurata.<br><small>Aggiungi GEMINI_API_KEY nel file .env sul server.</small></p>`;
} else {
resultDiv.innerHTML = `<p style="color:var(--danger)">❌ ${escapeHtml(result.error || 'Errore nell\'identificazione')}</p>
<button class="btn btn-secondary full-width mt-2" onclick="retakePhotoAI()">🔄 Riprova</button>`;
}
return;
}
const id = result.identified;
const matches = result.off_matches || [];
let html = `<h4>🤖 Prodotto identificato</h4>`;
html += `<div class="ai-identified-card">`;
html += `<strong>${escapeHtml(id.name)}</strong>`;
if (id.brand) html += ` <span style="color:var(--text-muted)">- ${escapeHtml(id.brand)}</span>`;
if (id.description) html += `<p style="font-size:0.85rem;color:var(--text-light);margin:4px 0 0">${escapeHtml(id.description)}</p>`;
html += `</div>`;
if (matches.length > 0) {
html += `<h4 style="margin-top:16px">📦 Prodotti corrispondenti</h4>`;
html += `<div class="ai-matches-list">`;
matches.forEach((m, idx) => {
html += `<div class="ai-match-item" onclick="selectAIMatch(${idx})">`;
if (m.image_url) {
html += `<img src="${m.image_url}" alt="" class="ai-match-img" onerror="this.style.display='none'">`;
}
html += `<div class="ai-match-info">`;
html += `<strong>${escapeHtml(m.name)}</strong>`;
if (m.brand) html += `<br><small>${escapeHtml(m.brand)}</small>`;
if (m.quantity_info) html += `<br><small style="color:var(--text-muted)">${escapeHtml(m.quantity_info)}</small>`;
html += `</div>`;
html += `<span class="ai-match-barcode">${m.barcode}</span>`;
html += `</div>`;
});
html += `</div>`;
}
// Option to save as-is without barcode
html += `<div style="margin-top:16px; border-top: 1px solid var(--bg-light); padding-top: 12px">`;
html += `<button class="btn btn-secondary full-width" onclick="saveAIProductDirect()">✏️ Salva senza barcode</button>`;
html += `</div>`;
resultDiv.innerHTML = html;
// Store data for later use
window._aiIdentified = id;
window._aiMatches = matches;
} catch (err) {
console.error('AI identify error:', err);
resultDiv.innerHTML = `<p style="color:var(--danger)">❌ Errore di connessione</p>
<button class="btn btn-secondary full-width mt-2" onclick="retakePhotoAI()">🔄 Riprova</button>`;
}
const samples = count / 4;
r = Math.round(r / samples);
g = Math.round(g / samples);
b = Math.round(b / samples);
// Provide a manual identification form since free AI APIs are limited
resultDiv.innerHTML = `
<h4>🤖 Identificazione Prodotto</h4>
<p style="font-size:0.85rem;color:var(--text-light);margin:8px 0">
L'analisi automatica ha dei limiti senza API a pagamento.
Puoi descrivere il prodotto qui sotto e lo salveremo nel database.
</p>
<form class="form" onsubmit="submitAIProduct(event)" style="margin-top:12px">
<div class="form-group">
<label>🏷️ Che prodotto è? *</label>
<input type="text" id="ai-product-name" class="form-input" required
placeholder="Es: Yogurt greco, Pasta Barilla..." autofocus>
</div>
<div class="form-group">
<label>🏢 Marca (se visibile)</label>
<input type="text" id="ai-product-brand" class="form-input" placeholder="Es: Müller, Barilla...">
</div>
<div class="form-group">
<label>📂 Categoria</label>
<select id="ai-product-category" class="form-input">
<option value="">-- Seleziona --</option>
${Object.entries(CATEGORY_ICONS).map(([k, v]) => `<option value="${k}">${v} ${k.charAt(0).toUpperCase() + k.slice(1)}</option>`).join('')}
</select>
</div>
<button type="submit" class="btn btn-large btn-accent full-width">✅ Salva e Continua</button>
</form>
`;
}
async function submitAIProduct(e) {
e.preventDefault();
async function selectAIMatch(idx) {
const match = window._aiMatches[idx];
if (!match) return;
showLoading(true);
const name = document.getElementById('ai-product-name').value;
const brand = document.getElementById('ai-product-brand').value;
const category = document.getElementById('ai-product-category').value;
// Save the captured image as base64 (we could save to file, but for simplicity use image_url)
const canvas = document.getElementById('ai-canvas');
// For a lightweight approach, don't store the actual image data in DB
try {
const result = await api('product_save', {}, 'POST', {
name, brand, category,
// Use the barcode to do a full lookup (gets all details)
const localResult = await api('search_barcode', { barcode: match.barcode });
if (localResult.found) {
currentProduct = localResult.product;
showLoading(false);
showProductAction();
return;
}
// Full lookup via OpenFoodFacts
const lookupResult = await api('lookup_barcode', { barcode: match.barcode });
if (lookupResult.found && lookupResult.product) {
const p = lookupResult.product;
const detected = detectUnitAndQuantity(p.quantity_info);
const notesParts = [];
if (p.quantity_info) notesParts.push(`Peso: ${p.quantity_info}`);
if (p.nutriscore) notesParts.push(`Nutriscore: ${p.nutriscore.toUpperCase()}`);
if (p.nova_group) notesParts.push(`NOVA: ${p.nova_group}`);
if (p.ecoscore) notesParts.push(`Ecoscore: ${p.ecoscore.toUpperCase()}`);
if (p.origin) notesParts.push(`Origine: ${p.origin}`);
const saveResult = await api('product_save', {}, 'POST', {
barcode: match.barcode,
name: p.name || match.name,
brand: p.brand || match.brand || '',
category: p.category || '',
image_url: p.image_url || match.image_url || '',
unit: detected.unit,
default_quantity: detected.quantity,
notes: notesParts.join(' · '),
});
if (saveResult.id) {
currentProduct = {
id: saveResult.id,
barcode: match.barcode,
name: p.name || match.name,
brand: p.brand || match.brand || '',
category: p.category || '',
image_url: p.image_url || match.image_url || '',
unit: detected.unit,
default_quantity: detected.quantity,
weight_info: p.quantity_info || '',
};
showLoading(false);
showProductAction();
return;
}
}
// Fallback: save with basic info from match
const saveResult = await api('product_save', {}, 'POST', {
barcode: match.barcode,
name: match.name,
brand: match.brand || '',
category: match.category || '',
image_url: match.image_url || '',
unit: 'pz',
default_quantity: 1,
});
if (result.success) {
currentProduct = { id: result.id, name, brand, category, unit: 'pz', default_quantity: 1 };
if (saveResult.id) {
currentProduct = { id: saveResult.id, barcode: match.barcode, name: match.name, brand: match.brand || '', category: match.category || '', image_url: match.image_url || '', unit: 'pz', default_quantity: 1 };
showLoading(false);
showToast('Prodotto identificato e salvato!', 'success');
showProductAction();
} else {
showLoading(false);
showToast('Errore nel salvataggio', 'error');
}
} catch (err) {
showLoading(false);
console.error('AI match select error:', err);
showToast('Errore di connessione', 'error');
}
}
async function saveAIProductDirect() {
const id = window._aiIdentified;
if (!id) return;
showLoading(true);
try {
const result = await api('product_save', {}, 'POST', {
name: id.name,
brand: id.brand || '',
category: id.category || '',
unit: 'pz',
default_quantity: 1,
});
if (result.success || result.id) {
currentProduct = { id: result.id, name: id.name, brand: id.brand || '', category: id.category || '', unit: 'pz', default_quantity: 1 };
showLoading(false);
showToast('Prodotto salvato!', 'success');
showProductAction();
} else {
showLoading(false);