Add Gemini Chat: conversational AI assistant for kitchen help

- Gemini star icon button next to camera in header
- Full chat page with message bubbles, typing indicator
- Conversation history persisted in localStorage (last 50 messages)
- System context includes: full inventory with expiry dates, appliances, dietary restrictions
- Multi-turn conversation with Gemini 2.0 Flash
- Pre-built suggestion chips: snack, juice/smoothie, light meal, use expiring items
- Clear chat button for fresh conversations
- Indigo/purple themed UI matching Gemini branding
- PHP gemini_chat API endpoint with inventory context injection
This commit is contained in:
dadaloop82
2026-03-11 15:26:19 +00:00
parent ff1f27fe8d
commit af3b5941a0
5 changed files with 618 additions and 3 deletions
+165
View File
@@ -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 = <<<PROMPT
Sei un assistente cucina italiano esperto, amichevole e conciso. L'utente ha una dispensa e ti chiede consigli su cosa preparare.
CONTESTO - INGREDIENTI DISPONIBILI IN DISPENSA:
{$ingredientsText}
{$appliancesText}{$dietaryText}
REGOLE:
1. Rispondi SEMPRE in italiano, in modo colloquiale e amichevole
2. Usa SOLO gli ingredienti dalla dispensa dell'utente (più acqua, sale, pepe, olio che si presumono sempre disponibili)
3. Dai priorità agli ingredienti in scadenza
4. Sii conciso: non fare liste chilometriche, vai al sodo
5. Se l'utente chiede una ricetta o preparazione, dai istruzioni chiare con quantità
6. Se non ci sono ingredienti adatti per la richiesta, dillo onestamente e suggerisci alternative
7. Puoi suggerire combinazioni creative
8. Quando menzioni quantità, usa le stesse unità di misura della dispensa
9. Ricorda il contesto della conversazione precedente
PROMPT;
// Build conversation for Gemini
$contents = [];
// System instruction as first user+model turn
$contents[] = [
'role' => '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
+241
View File
@@ -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; }
}
+172
View File
@@ -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', '<div class="chat-typing"><span></span><span></span><span></span></div>', 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, '<strong>$1</strong>');
// Italic *text*
html = html.replace(/\*(.+?)\*/g, '<em>$1</em>');
// Lists
html = html.replace(/^- (.+)$/gm, '<li>$1</li>');
html = html.replace(/(<li>.*<\/li>)/s, '<ul>$1</ul>');
// Numbered lists
html = html.replace(/^(\d+)\. (.+)$/gm, '<li>$2</li>');
// Line breaks
html = html.replace(/\n/g, '<br>');
// Clean up consecutive ul tags
html = html.replace(/<\/ul>\s*<br>\s*<ul>/g, '');
return html;
}
function renderChatHistory() {
const container = document.getElementById('chat-messages');
if (chatHistory.length === 0) return;
// Hide welcome
const welcome = container.querySelector('.chat-welcome');
if (welcome) welcome.style.display = 'none';
chatHistory.forEach(msg => {
if (msg.role === 'user') {
appendChatBubble('user', msg.text);
} else {
appendChatBubble('gemini', formatChatReply(msg.text));
}
});
scrollChatBottom();
}
function scrollChatBottom() {
const container = document.getElementById('chat-messages');
setTimeout(() => container.scrollTop = container.scrollHeight, 50);
}
function clearChat() {
chatHistory = [];
localStorage.removeItem('gemini_chat_history');
const container = document.getElementById('chat-messages');
container.innerHTML = `
<div class="chat-welcome">
<svg class="gemini-icon-lg" viewBox="0 0 24 24" width="48" height="48" fill="#6366f1"><path d="M12 0C12 6.627 6.627 12 0 12c6.627 0 12 5.373 12 12 0-6.627 5.373-12 12-12-6.627 0-12-5.373-12-12z"/></svg>
<h3>Ciao! Sono il tuo assistente cucina</h3>
<p>Chiedimi di prepararti un succo, uno spuntino, un piatto veloce... Conosco la tua dispensa, i tuoi elettrodomestici e le tue preferenze!</p>
<div class="chat-suggestions">
<button class="chat-suggestion" onclick="sendChatSuggestion('Cosa posso preparare per uno spuntino veloce?')">🍿 Spuntino veloce</button>
<button class="chat-suggestion" onclick="sendChatSuggestion('Fammi un succo o frullato con quello che ho')">🥤 Succo/Frullato</button>
<button class="chat-suggestion" onclick="sendChatSuggestion('Ho fame ma voglio qualcosa di leggero')">🥗 Qualcosa di leggero</button>
<button class="chat-suggestion" onclick="sendChatSuggestion('Cosa sta per scadere e come posso usarlo?')">⏰ Usa le scadenze</button>
</div>
</div>
`;
showToast('Chat cancellata', 'success');
}
function saveChatHistory() {
// Keep last 50 messages max
if (chatHistory.length > 50) chatHistory = chatHistory.slice(-50);
localStorage.setItem('gemini_chat_history', JSON.stringify(chatHistory));
}
// ===== INITIALIZATION =====
document.addEventListener('DOMContentLoaded', () => {
showPage('dashboard');
BIN
View File
Binary file not shown.
+40 -3
View File
@@ -19,9 +19,14 @@
<header class="app-header">
<div class="header-content">
<h1 class="header-title" onclick="showPage('dashboard')">🏠 Dispensa</h1>
<button class="header-scan-btn" onclick="showPage('scan')" title="Scansiona prodotto">
📷
</button>
<div class="header-actions">
<button class="header-scan-btn header-gemini-btn" onclick="showPage('chat')" title="Chat con Gemini">
<svg class="gemini-icon" viewBox="0 0 24 24" width="28" height="28" fill="white"><path d="M12 0C12 6.627 6.627 12 0 12c6.627 0 12 5.373 12 12 0-6.627 5.373-12 12-12-6.627 0-12-5.373-12-12z"/></svg>
</button>
<button class="header-scan-btn" onclick="showPage('scan')" title="Scansiona prodotto">
📷
</button>
</div>
</div>
</header>
@@ -597,6 +602,38 @@
<div id="settings-status" class="settings-status" style="display:none"></div>
</section>
<!-- ===== GEMINI CHAT ===== -->
<section class="page" id="page-chat">
<div class="chat-container">
<div class="chat-header-bar">
<div class="chat-header-info">
<svg class="gemini-icon-sm" viewBox="0 0 24 24" width="22" height="22" fill="#6366f1"><path d="M12 0C12 6.627 6.627 12 0 12c6.627 0 12 5.373 12 12 0-6.627 5.373-12 12-12-6.627 0-12-5.373-12-12z"/></svg>
<span class="chat-title">Gemini Chef</span>
</div>
<button class="btn-chat-clear" onclick="clearChat()" title="Nuova conversazione">🗑️</button>
</div>
<div class="chat-messages" id="chat-messages">
<div class="chat-welcome">
<svg class="gemini-icon-lg" viewBox="0 0 24 24" width="48" height="48" fill="#6366f1"><path d="M12 0C12 6.627 6.627 12 0 12c6.627 0 12 5.373 12 12 0-6.627 5.373-12 12-12-6.627 0-12-5.373-12-12z"/></svg>
<h3>Ciao! Sono il tuo assistente cucina</h3>
<p>Chiedimi di prepararti un succo, uno spuntino, un piatto veloce... Conosco la tua dispensa, i tuoi elettrodomestici e le tue preferenze!</p>
<div class="chat-suggestions">
<button class="chat-suggestion" onclick="sendChatSuggestion('Cosa posso preparare per uno spuntino veloce?')">🍿 Spuntino veloce</button>
<button class="chat-suggestion" onclick="sendChatSuggestion('Fammi un succo o frullato con quello che ho')">🥤 Succo/Frullato</button>
<button class="chat-suggestion" onclick="sendChatSuggestion('Ho fame ma voglio qualcosa di leggero')">🥗 Qualcosa di leggero</button>
<button class="chat-suggestion" onclick="sendChatSuggestion('Cosa sta per scadere e come posso usarlo?')">⏰ Usa le scadenze</button>
</div>
</div>
</div>
<div class="chat-input-bar">
<input type="text" id="chat-input" class="chat-input" placeholder="Chiedi qualcosa..." onkeydown="if(event.key==='Enter')sendChatMessage()">
<button class="btn-chat-send" id="btn-chat-send" onclick="sendChatMessage()">
<svg viewBox="0 0 24 24" width="22" height="22" fill="white"><path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z"/></svg>
</button>
</div>
</div>
</section>
</main>
<!-- Bottom Navigation -->