diff --git a/api/index.php b/api/index.php index c026c9b..efd55dc 100644 --- a/api/index.php +++ b/api/index.php @@ -95,6 +95,10 @@ try { geminiIdentifyProduct(); break; + case 'gemini_chat': + geminiChat($db); + break; + // ===== BRING! SHOPPING LIST ===== case 'bring_list': bringGetList(); @@ -807,6 +811,167 @@ function geminiReadExpiry(): void { ]); } +// ===== GEMINI CHAT ===== +function geminiChat(PDO $db): void { + // Load API key + $envFile = __DIR__ . '/../.env'; + $apiKey = ''; + if (file_exists($envFile)) { + $lines = file($envFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES); + foreach ($lines as $line) { + if (strpos($line, '#') === 0) continue; + if (strpos($line, '=') !== false) { + list($key, $val) = explode('=', $line, 2); + if (trim($key) === 'GEMINI_API_KEY') { + $apiKey = trim($val); + } + } + } + } + + if (empty($apiKey)) { + echo json_encode(['success' => false, 'error' => 'no_api_key']); + return; + } + + $input = json_decode(file_get_contents('php://input'), true); + $message = $input['message'] ?? ''; + $history = $input['history'] ?? []; + $appliances = $input['appliances'] ?? []; + $dietaryRestrictions = $input['dietary_restrictions'] ?? ''; + + if (empty($message)) { + echo json_encode(['success' => false, 'error' => 'Messaggio vuoto']); + return; + } + + // Fetch inventory context + $stmt = $db->query(" + SELECT p.name, p.brand, p.category, i.quantity, p.unit, i.location, i.expiry_date, + CASE WHEN i.expiry_date IS NOT NULL THEN julianday(i.expiry_date) - julianday('now') ELSE 999 END AS days_left + FROM inventory i + JOIN products p ON p.id = i.product_id + WHERE i.quantity > 0 + ORDER BY days_left ASC + "); + $items = $stmt->fetchAll(PDO::FETCH_ASSOC); + + $ingredientLines = []; + foreach ($items as $item) { + $line = "- {$item['name']}"; + if ($item['brand']) $line .= " ({$item['brand']})"; + $line .= ": {$item['quantity']} {$item['unit']}"; + if ($item['expiry_date']) { + $daysLeft = intval($item['days_left']); + if ($daysLeft < 0) { + $line .= " [SCADUTO da " . abs($daysLeft) . " giorni]"; + } elseif ($daysLeft <= 3) { + $line .= " [SCADE TRA $daysLeft GIORNI]"; + } elseif ($daysLeft <= 7) { + $line .= " [scade tra $daysLeft giorni]"; + } + } + $line .= " (in {$item['location']})"; + $ingredientLines[] = $line; + } + $ingredientsText = implode("\n", $ingredientLines); + + $appliancesText = ''; + if (!empty($appliances)) { + $appliancesText = "\nElettodomestici disponibili: " . implode(', ', $appliances) . " (più fornelli e forno sempre disponibili)."; + } + + $dietaryText = ''; + if (!empty($dietaryRestrictions)) { + $dietaryText = "\nRestrizioni alimentari dell'utente: {$dietaryRestrictions}. Rispetta SEMPRE queste restrizioni."; + } + + $systemPrompt = << 'user', + 'parts' => [['text' => $systemPrompt]] + ]; + $contents[] = [ + 'role' => 'model', + 'parts' => [['text' => 'Ciao! Sono il tuo assistente cucina. Conosco tutto quello che hai in dispensa e sono pronto ad aiutarti. Cosa ti va di preparare? 😊']] + ]; + + // Add conversation history + foreach ($history as $msg) { + $role = ($msg['role'] === 'user') ? 'user' : 'model'; + $contents[] = [ + 'role' => $role, + 'parts' => [['text' => $msg['text']]] + ]; + } + + // Add current message + $contents[] = [ + 'role' => 'user', + 'parts' => [['text' => $message]] + ]; + + $url = "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key={$apiKey}"; + + $payload = [ + 'contents' => $contents, + 'generationConfig' => [ + 'temperature' => 0.8, + 'maxOutputTokens' => 1500 + ] + ]; + + $ch = curl_init($url); + curl_setopt_array($ch, [ + CURLOPT_POST => true, + CURLOPT_POSTFIELDS => json_encode($payload), + CURLOPT_HTTPHEADER => ['Content-Type: application/json'], + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => 60, + ]); + + $response = curl_exec($ch); + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + if ($response === false || $httpCode !== 200) { + echo json_encode(['success' => false, 'error' => 'Errore API Gemini', 'http_code' => $httpCode]); + return; + } + + $data = json_decode($response, true); + $reply = $data['candidates'][0]['content']['parts'][0]['text'] ?? ''; + + if (empty($reply)) { + echo json_encode(['success' => false, 'error' => 'Risposta vuota da Gemini']); + return; + } + + echo json_encode(['success' => true, 'reply' => $reply]); +} + // ===== RECIPE GENERATION WITH GEMINI ===== function generateRecipe(PDO $db): void { // Load API key from .env diff --git a/assets/css/style.css b/assets/css/style.css index cc983fa..6afbc81 100644 --- a/assets/css/style.css +++ b/assets/css/style.css @@ -113,6 +113,30 @@ body { padding-bottom: 2px; } +.header-actions { + display: flex; + gap: 10px; + align-items: center; +} + +.header-gemini-btn { + width: 44px; + height: 44px; + font-size: 1.4rem; + animation: none; + border-width: 2px; + background: rgba(99, 102, 241, 0.35); + border-color: rgba(199, 210, 254, 0.6); +} + +.header-gemini-btn:active { + background: rgba(99, 102, 241, 0.55); +} + +.gemini-icon { + filter: drop-shadow(0 1px 2px rgba(0,0,0,0.2)); +} + .header-scan-btn:active { transform: scale(0.9); background: rgba(255,255,255,0.4); @@ -2710,3 +2734,220 @@ body { border-radius: 12px; white-space: nowrap; } + +/* ===== GEMINI CHAT ===== */ +.chat-container { + display: flex; + flex-direction: column; + height: calc(100vh - var(--header-height) - var(--nav-height) - 24px); + background: var(--bg); + border-radius: var(--radius); + overflow: hidden; +} + +.chat-header-bar { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 14px; + background: white; + border-bottom: 1px solid var(--border); + border-radius: var(--radius) var(--radius) 0 0; +} + +.chat-header-info { + display: flex; + align-items: center; + gap: 8px; +} + +.chat-title { + font-weight: 700; + font-size: 1rem; + color: #4338ca; +} + +.btn-chat-clear { + background: none; + border: none; + font-size: 1.2rem; + cursor: pointer; + padding: 4px 8px; + border-radius: 8px; + opacity: 0.6; +} + +.btn-chat-clear:active { + background: var(--bg); + opacity: 1; +} + +.chat-messages { + flex: 1; + overflow-y: auto; + padding: 16px 12px; + display: flex; + flex-direction: column; + gap: 10px; + -webkit-overflow-scrolling: touch; +} + +.chat-welcome { + text-align: center; + padding: 30px 16px; + color: var(--text-light); +} + +.chat-welcome h3 { + margin: 12px 0 6px; + color: var(--text); + font-size: 1.1rem; +} + +.chat-welcome p { + font-size: 0.85rem; + line-height: 1.5; + max-width: 300px; + margin: 0 auto 20px; +} + +.gemini-icon-lg { + opacity: 0.3; +} + +.chat-suggestions { + display: flex; + flex-wrap: wrap; + gap: 8px; + justify-content: center; +} + +.chat-suggestion { + background: white; + border: 1.5px solid #e0e7ff; + border-radius: 20px; + padding: 8px 14px; + font-size: 0.82rem; + cursor: pointer; + color: #4338ca; + font-weight: 500; + transition: all 0.15s; +} + +.chat-suggestion:active { + background: #e0e7ff; + transform: scale(0.97); +} + +.chat-bubble { + max-width: 85%; + padding: 10px 14px; + border-radius: 18px; + font-size: 0.9rem; + line-height: 1.5; + word-break: break-word; + animation: chatFadeIn 0.2s ease; +} + +@keyframes chatFadeIn { + from { opacity: 0; transform: translateY(8px); } + to { opacity: 1; transform: translateY(0); } +} + +.chat-user { + align-self: flex-end; + background: #4338ca; + color: white; + border-bottom-right-radius: 6px; +} + +.chat-gemini { + align-self: flex-start; + background: white; + color: var(--text); + border-bottom-left-radius: 6px; + box-shadow: 0 1px 3px rgba(0,0,0,0.06); +} + +.chat-gemini strong { + color: #4338ca; +} + +.chat-gemini ul, .chat-gemini ol { + margin: 4px 0; + padding-left: 20px; +} + +.chat-gemini li { + margin-bottom: 2px; +} + +.chat-input-bar { + display: flex; + gap: 8px; + padding: 10px 12px; + background: white; + border-top: 1px solid var(--border); +} + +.chat-input { + flex: 1; + border: 1.5px solid var(--border); + border-radius: 24px; + padding: 10px 16px; + font-size: 0.9rem; + outline: none; + transition: border-color 0.2s; +} + +.chat-input:focus { + border-color: #6366f1; +} + +.btn-chat-send { + width: 42px; + height: 42px; + border-radius: 50%; + border: none; + background: #4338ca; + color: white; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.15s; + flex-shrink: 0; +} + +.btn-chat-send:active { + transform: scale(0.92); + background: #3730a3; +} + +.btn-chat-send:disabled { + background: #a5b4fc; + cursor: default; +} + +/* Typing indicator */ +.chat-typing { + display: flex; + gap: 4px; + padding: 4px 0; +} + +.chat-typing span { + width: 8px; + height: 8px; + background: #a5b4fc; + border-radius: 50%; + animation: chatTyping 1.4s infinite; +} + +.chat-typing span:nth-child(2) { animation-delay: 0.2s; } +.chat-typing span:nth-child(3) { animation-delay: 0.4s; } + +@keyframes chatTyping { + 0%, 60%, 100% { transform: translateY(0); opacity: 0.4; } + 30% { transform: translateY(-6px); opacity: 1; } +} diff --git a/assets/js/app.js b/assets/js/app.js index 3fc1274..59a928b 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -521,6 +521,7 @@ function showPage(pageId, param = null) { case 'log': loadLog(); break; case 'ai': initAICamera(); break; case 'settings': loadSettingsUI(); break; + case 'chat': initChat(); break; } // Stop scanner when leaving scan page @@ -3469,6 +3470,177 @@ async function generateRecipe() { } } +// ===== GEMINI CHAT ===== +let chatHistory = []; +let chatInventoryContext = null; + +function initChat() { + // Load chat history from localStorage + const saved = localStorage.getItem('gemini_chat_history'); + if (saved) { + try { + chatHistory = JSON.parse(saved); + renderChatHistory(); + } catch(e) { chatHistory = []; } + } + // Pre-load inventory context + loadChatContext(); + // Focus input + setTimeout(() => { + const input = document.getElementById('chat-input'); + if (input) input.focus(); + }, 300); +} + +async function loadChatContext() { + try { + const data = await api('inventory_list'); + chatInventoryContext = data.inventory || []; + } catch(e) { chatInventoryContext = []; } +} + +function sendChatSuggestion(text) { + document.getElementById('chat-input').value = text; + sendChatMessage(); +} + +async function sendChatMessage() { + const input = document.getElementById('chat-input'); + const text = input.value.trim(); + if (!text) return; + + input.value = ''; + + // Hide welcome if first message + const welcome = document.querySelector('.chat-welcome'); + if (welcome) welcome.style.display = 'none'; + + // Add user message + chatHistory.push({ role: 'user', text }); + appendChatBubble('user', text); + saveChatHistory(); + + // Show typing indicator + const typingEl = appendChatBubble('gemini', '
', true); + scrollChatBottom(); + + // Disable send + const btn = document.getElementById('btn-chat-send'); + btn.disabled = true; + + try { + const settings = getSettings(); + const result = await api('gemini_chat', {}, 'POST', { + message: text, + history: chatHistory.slice(0, -1).slice(-20), // last 20 messages for context + appliances: settings.appliances || [], + dietary_restrictions: settings.dietary_restrictions || '' + }); + + // Remove typing indicator + typingEl.remove(); + + if (result.success) { + chatHistory.push({ role: 'gemini', text: result.reply }); + appendChatBubble('gemini', formatChatReply(result.reply)); + } else { + const errMsg = result.error === 'no_api_key' ? 'Configura la chiave API Gemini nelle impostazioni.' : (result.error || 'Errore nella risposta'); + appendChatBubble('gemini', `⚠️ ${escapeHtml(errMsg)}`); + } + } catch(err) { + typingEl.remove(); + appendChatBubble('gemini', '⚠️ Errore di connessione'); + } + + btn.disabled = false; + saveChatHistory(); + scrollChatBottom(); +} + +function appendChatBubble(role, html, isRaw = false) { + const container = document.getElementById('chat-messages'); + const bubble = document.createElement('div'); + bubble.className = `chat-bubble chat-${role}`; + if (isRaw) { + bubble.innerHTML = html; + } else if (role === 'user') { + bubble.textContent = html; + } else { + bubble.innerHTML = html; + } + container.appendChild(bubble); + scrollChatBottom(); + return bubble; +} + +function formatChatReply(text) { + // Convert markdown-like formatting + let html = escapeHtml(text); + // Bold **text** + html = html.replace(/\*\*(.+?)\*\*/g, '$1'); + // Italic *text* + html = html.replace(/\*(.+?)\*/g, '$1'); + // Lists + html = html.replace(/^- (.+)$/gm, '
  • $1
  • '); + html = html.replace(/(
  • .*<\/li>)/s, ''); + // Numbered lists + html = html.replace(/^(\d+)\. (.+)$/gm, '
  • $2
  • '); + // Line breaks + html = html.replace(/\n/g, '
    '); + // Clean up consecutive ul tags + html = html.replace(/<\/ul>\s*
    \s*