chore: auto-merge develop → main
Triggered by: 073b4b9 v1.7.8: Trasferisci a Ricette dalla chat (refactor)
This commit is contained in:
+1
-1
@@ -8,7 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
## [1.7.8] - 2026-05-10
|
## [1.7.8] - 2026-05-10
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
- **Usa ingredienti dalla chat** — Quando la chat con Gemini Chef genera una ricetta, compare il bottone "🥄 Usa ingredienti" sotto la risposta. Premendolo, il backend analizza la ricetta e abbina gli ingredienti all'inventario tramite fuzzy matching (stessa logica della sezione Ricette). Vengono mostrati i pulsanti "📦 Usa" per ogni ingrediente disponibile in dispensa, con posizione e scadenza. Il flusso di scalatura inventario è identico a quello della sezione Ricette.
|
- **Trasferisci a Ricette dalla chat** — Quando la chat con Gemini Chef genera una ricetta, compare il bottone "📥 Trasferisci a Ricette". Premendolo, Gemini converte il testo in JSON strutturato completo (titolo, pasti, ingredienti, passi), il backend arricchisce ogni ingrediente con product_id e location via fuzzy-match (identico a generateRecipe), la ricetta viene salvata in archivio e si apre direttamente nella sezione Ricette con tutti i pulsanti "Usa" e la modalità cottura completa.
|
||||||
|
|
||||||
## [1.7.7] - 2026-05-10
|
## [1.7.7] - 2026-05-10
|
||||||
|
|
||||||
|
|||||||
+32
-25
@@ -111,7 +111,7 @@ function checkRateLimit(string $action): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Determine limit based on action
|
// Determine limit based on action
|
||||||
$aiActions = ['gemini_readExpiry', 'gemini_chat', 'gemini_identify', 'gemini_suggest_shopping', 'chat_extract_recipe'];
|
$aiActions = ['gemini_readExpiry', 'gemini_chat', 'gemini_identify', 'gemini_suggest_shopping', 'chat_to_recipe'];
|
||||||
$loginActions = [];
|
$loginActions = [];
|
||||||
$recipeActions = ['generate_recipe', 'generate_recipe_stream'];
|
$recipeActions = ['generate_recipe', 'generate_recipe_stream'];
|
||||||
$errorActions = ['report_error', 'check_update'];
|
$errorActions = ['report_error', 'check_update'];
|
||||||
@@ -335,8 +335,8 @@ try {
|
|||||||
geminiChat($db);
|
geminiChat($db);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'chat_extract_recipe':
|
case 'chat_to_recipe':
|
||||||
chatExtractRecipe($db);
|
chatToRecipe($db);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
// ===== BRING! SHOPPING LIST =====
|
// ===== BRING! SHOPPING LIST =====
|
||||||
@@ -3595,8 +3595,8 @@ PROMPT;
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== CHAT: EXTRACT RECIPE INGREDIENTS =====
|
// ===== CHAT: CONVERT CHAT RECIPE TO STRUCTURED RECIPE =====
|
||||||
function chatExtractRecipe(PDO $db): void {
|
function chatToRecipe(PDO $db): void {
|
||||||
$apiKey = env('GEMINI_API_KEY');
|
$apiKey = env('GEMINI_API_KEY');
|
||||||
if (empty($apiKey)) {
|
if (empty($apiKey)) {
|
||||||
echo json_encode(['success' => false, 'error' => 'no_api_key']);
|
echo json_encode(['success' => false, 'error' => 'no_api_key']);
|
||||||
@@ -3605,6 +3605,7 @@ function chatExtractRecipe(PDO $db): void {
|
|||||||
|
|
||||||
$input = json_decode(file_get_contents('php://input'), true);
|
$input = json_decode(file_get_contents('php://input'), true);
|
||||||
$replyText = trim($input['text'] ?? '');
|
$replyText = trim($input['text'] ?? '');
|
||||||
|
$lang = recipeNormalizeLang($input['lang'] ?? 'it');
|
||||||
|
|
||||||
if (empty($replyText)) {
|
if (empty($replyText)) {
|
||||||
echo json_encode(['success' => false, 'error' => 'empty_text']);
|
echo json_encode(['success' => false, 'error' => 'empty_text']);
|
||||||
@@ -3622,9 +3623,25 @@ function chatExtractRecipe(PDO $db): void {
|
|||||||
");
|
");
|
||||||
$items = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
$items = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
// --- Gemini call: extract ingredient list only (no inventory in prompt → tiny output) ---
|
// Ask Gemini to convert the chat recipe text into the full structured recipe JSON.
|
||||||
// The PHP fuzzy-matching below does all the inventory matching, exactly like generateRecipe.
|
// Prompt is tiny — no inventory sent to Gemini (PHP does all the matching below).
|
||||||
$prompt = "Extract all ingredients from the recipe text below.\nReturn ONLY a compact JSON array — no markdown, no extra text.\nEach element: {\"name\":\"...\",\"qty\":\"...\",\"qty_number\":0.0,\"unit\":\"g|ml|pz|conf|kg|l\"}\n\nRECIPE:\n{$replyText}";
|
$prompt = <<<PROMPT
|
||||||
|
Convert the recipe text below to a JSON object. Return ONLY the JSON, no markdown.
|
||||||
|
|
||||||
|
Fields:
|
||||||
|
- title: string
|
||||||
|
- meal: one of "colazione","pranzo","cena","dolce","succo" (infer from context, default "pranzo")
|
||||||
|
- servings: integer (default 2 if not mentioned)
|
||||||
|
- prep_time: string or null
|
||||||
|
- cook_time: string or null
|
||||||
|
- ingredients: array of {"name":"...","qty":"...","qty_number":0.0,"unit":"g|ml|pz|conf|kg|l","from_pantry":true}
|
||||||
|
— set from_pantry=true for ALL ingredients (pantry matching is done server-side)
|
||||||
|
- steps: array of strings (one string per step, plain text without step numbers)
|
||||||
|
- nutrition_note: string or null
|
||||||
|
|
||||||
|
RECIPE TEXT:
|
||||||
|
{$replyText}
|
||||||
|
PROMPT;
|
||||||
|
|
||||||
$payload = [
|
$payload = [
|
||||||
'contents' => [['role' => 'user', 'parts' => [['text' => $prompt]]]],
|
'contents' => [['role' => 'user', 'parts' => [['text' => $prompt]]]],
|
||||||
@@ -3644,35 +3661,25 @@ function chatExtractRecipe(PDO $db): void {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Strip markdown code fences if present
|
|
||||||
$text = preg_replace('/^```(?:json)?\s*/i', '', $text);
|
$text = preg_replace('/^```(?:json)?\s*/i', '', $text);
|
||||||
$text = preg_replace('/\s*```$/i', '', $text);
|
$text = preg_replace('/\s*```$/i', '', $text);
|
||||||
$text = trim($text);
|
$text = trim($text);
|
||||||
|
|
||||||
$ingredients = json_decode($text, true);
|
$recipe = json_decode($text, true);
|
||||||
if (!is_array($ingredients) || empty($ingredients)) {
|
if (!is_array($recipe) || empty($recipe['title'])) {
|
||||||
echo json_encode(['success' => false, 'error' => 'parse_error', 'raw' => mb_substr($text, 0, 500)]);
|
echo json_encode(['success' => false, 'error' => 'parse_error', 'raw' => mb_substr($text, 0, 500)]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mark all extracted ingredients as from_pantry=true so the enrichment logic tries to match them all
|
// Enrich ingredients with product_id/location — same fuzzy-match as generateRecipe
|
||||||
foreach ($ingredients as &$ing) {
|
if (!empty($recipe['ingredients'])) {
|
||||||
$ing['from_pantry'] = true;
|
_enrichChatIngredients($recipe['ingredients'], $items);
|
||||||
}
|
|
||||||
unset($ing);
|
|
||||||
|
|
||||||
// PHP fuzzy-match against full inventory — same logic as generateRecipe
|
|
||||||
_enrichChatIngredients($ingredients, $items);
|
|
||||||
|
|
||||||
// Extract recipe title from the reply text (look for bold title at start)
|
|
||||||
$recipeTitle = '';
|
|
||||||
if (preg_match('/\*\*([^*\n]{3,60})\*\*/u', $replyText, $m)) {
|
|
||||||
$recipeTitle = trim($m[1]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
echo json_encode(['success' => true, 'ingredients' => $ingredients, 'title' => $recipeTitle]);
|
echo json_encode(['success' => true, 'recipe' => $recipe]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
function _enrichChatIngredients(array &$ingredients, array $items): void {
|
function _enrichChatIngredients(array &$ingredients, array $items): void {
|
||||||
if (empty($ingredients) || empty($items)) return;
|
if (empty($ingredients) || empty($items)) return;
|
||||||
|
|
||||||
|
|||||||
+1
-24
@@ -5630,7 +5630,7 @@ body.cooking-mode-active .app-header {
|
|||||||
30% { transform: translateY(-6px); opacity: 1; }
|
30% { transform: translateY(-6px); opacity: 1; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ====== Chat Recipe Ingredient Panel ====== */
|
/* ====== Chat Transfer to Recipes button ====== */
|
||||||
.btn-chat-use-recipe {
|
.btn-chat-use-recipe {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
margin-top: 10px;
|
margin-top: 10px;
|
||||||
@@ -5652,29 +5652,6 @@ body.cooking-mode-active .app-header {
|
|||||||
cursor: default;
|
cursor: default;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-recipe-panel-container {
|
|
||||||
margin-top: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-recipe-panel {
|
|
||||||
background: var(--card-bg, #1e293b);
|
|
||||||
border: 1px solid rgba(99,102,241,0.3);
|
|
||||||
border-radius: 12px;
|
|
||||||
padding: 12px 14px;
|
|
||||||
margin-top: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-recipe-panel-title {
|
|
||||||
font-size: 0.9rem;
|
|
||||||
margin-bottom: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-recipe-panel-subtitle {
|
|
||||||
font-size: 0.75rem;
|
|
||||||
color: var(--text-muted, #94a3b8);
|
|
||||||
margin-bottom: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ====== Recipe Archive ====== */
|
/* ====== Recipe Archive ====== */
|
||||||
.recipe-archive {
|
.recipe-archive {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
+7
-86
@@ -11355,7 +11355,7 @@ async function submitRecipeUse(useAll) {
|
|||||||
btn.textContent = '⏳...';
|
btn.textContent = '⏳...';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const recipeTitle = _cachedRecipe?.recipe?.title || _chatRecipeTitle || '';
|
const recipeTitle = _cachedRecipe?.recipe?.title || '';
|
||||||
const result = await api('inventory_use', {}, 'POST', {
|
const result = await api('inventory_use', {}, 'POST', {
|
||||||
product_id: productId,
|
product_id: productId,
|
||||||
quantity: qty,
|
quantity: qty,
|
||||||
@@ -12382,7 +12382,6 @@ async function generateRecipe() {
|
|||||||
let chatHistory = [];
|
let chatHistory = [];
|
||||||
let chatInventoryContext = null;
|
let chatInventoryContext = null;
|
||||||
let _chatSavedCount = 0; // track how many messages already saved to DB
|
let _chatSavedCount = 0; // track how many messages already saved to DB
|
||||||
let _chatRecipeTitle = ''; // title of last recipe extracted from chat (used in confirmRecipeUse notes)
|
|
||||||
|
|
||||||
function initChat() {
|
function initChat() {
|
||||||
// Load chat history from DB
|
// Load chat history from DB
|
||||||
@@ -12425,80 +12424,6 @@ function _looksLikeRecipe(text) {
|
|||||||
return hasIngredients && (hasPreparation || hasStepNumbers);
|
return hasIngredients && (hasPreparation || hasStepNumbers);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function chatExtractIngredients(btn, replyText) {
|
|
||||||
btn.disabled = true;
|
|
||||||
btn.textContent = '⏳ Analisi in corso...';
|
|
||||||
|
|
||||||
const panel = btn.nextElementSibling;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const settings = getSettings();
|
|
||||||
const result = await api('chat_extract_recipe', {}, 'POST', {
|
|
||||||
text: replyText,
|
|
||||||
lang: settings.lang || 'it'
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!result.success || !result.ingredients) {
|
|
||||||
btn.textContent = '⚠️ ' + (result.error || t('error.generic'));
|
|
||||||
btn.disabled = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const matchedIngredients = result.ingredients.filter(i => i.product_id);
|
|
||||||
if (matchedIngredients.length === 0) {
|
|
||||||
btn.textContent = '📦 Nessun ingrediente trovato in dispensa';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
_chatRecipeTitle = result.title || '';
|
|
||||||
|
|
||||||
// Render inline ingredient panel
|
|
||||||
btn.style.display = 'none';
|
|
||||||
panel.innerHTML = _buildChatIngredientPanelHTML(result.ingredients, result.title);
|
|
||||||
panel.style.display = 'block';
|
|
||||||
|
|
||||||
} catch(err) {
|
|
||||||
btn.textContent = '⚠️ ' + t('error.connection');
|
|
||||||
btn.disabled = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function _buildChatIngredientPanelHTML(ingredients, title) {
|
|
||||||
let html = `<div class="chat-recipe-panel">`;
|
|
||||||
if (title) html += `<div class="chat-recipe-panel-title">🍽️ <strong>${escapeHtml(title)}</strong></div>`;
|
|
||||||
html += `<div class="chat-recipe-panel-subtitle">${t('chat.recipe_ingredients_from_pantry') || 'Ingredienti in dispensa'}</div>`;
|
|
||||||
html += `<ul class="recipe-ingredients">`;
|
|
||||||
|
|
||||||
ingredients.forEach((ing, idx) => {
|
|
||||||
if (ing.product_id) {
|
|
||||||
const qtyNum = ing.qty_number || 0;
|
|
||||||
const loc = (ing.location || 'dispensa').replace(/'/g, "\\'");
|
|
||||||
html += `<li class="recipe-ingredient" id="recipe-ing-${idx}">`;
|
|
||||||
html += `<span class="recipe-ing-text"><strong>${escapeHtml(ing.name)}</strong>${ing.brand ? ' <em>(' + escapeHtml(ing.brand) + ')</em>' : ''}: ${escapeHtml(ing.qty || '')} ✅`;
|
|
||||||
let details = [];
|
|
||||||
const ingredientLocLabels = Object.fromEntries(Object.entries(LOCATIONS).map(([k,v]) => [k, `${v.icon} ${v.label}`]));
|
|
||||||
details.push(ingredientLocLabels[ing.location] || ('📍 ' + ing.location));
|
|
||||||
if (ing.expiry_date) {
|
|
||||||
const exp = new Date(ing.expiry_date);
|
|
||||||
const now = new Date(); now.setHours(0,0,0,0);
|
|
||||||
const diffDays = Math.round((exp - now) / 86400000);
|
|
||||||
if (diffDays < 0) details.push(t('expiry.badge_expired_ago').replace('{n}', Math.abs(diffDays)));
|
|
||||||
else if (diffDays <= 3) details.push(t('expiry.badge_expires_red').replace('{n}', diffDays));
|
|
||||||
else if (diffDays <= 7) details.push(t('expiry.badge_expires_yellow').replace('{n}', diffDays));
|
|
||||||
}
|
|
||||||
if (details.length) html += `<br><small class="recipe-ing-detail">${details.join(' · ')}</small>`;
|
|
||||||
html += `</span>`;
|
|
||||||
html += `<button class="btn-use-ingredient" onclick="useRecipeIngredient(${idx}, ${ing.product_id}, '${loc}', ${qtyNum}, this, '${(ing.qty || '').replace(/'/g, ''')}')" title="${t('cooking.ingredient_deduct_title') || 'Usa ingrediente'}">${t('cooking.ingredient_use_btn') || 'Usa'}</button>`;
|
|
||||||
html += `</li>`;
|
|
||||||
} else {
|
|
||||||
html += `<li class="recipe-ingredient"><span class="recipe-ing-text"><strong>${escapeHtml(ing.name)}</strong>: ${escapeHtml(ing.qty || '')} 🛒</span></li>`;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
html += `</ul></div>`;
|
|
||||||
return html;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function sendChatMessage() {
|
async function sendChatMessage() {
|
||||||
const input = document.getElementById('chat-input');
|
const input = document.getElementById('chat-input');
|
||||||
const text = input.value.trim();
|
const text = input.value.trim();
|
||||||
@@ -12538,18 +12463,14 @@ async function sendChatMessage() {
|
|||||||
if (result.success) {
|
if (result.success) {
|
||||||
chatHistory.push({ role: 'gemini', text: result.reply });
|
chatHistory.push({ role: 'gemini', text: result.reply });
|
||||||
const bubble = appendChatBubble('gemini', formatChatReply(result.reply));
|
const bubble = appendChatBubble('gemini', formatChatReply(result.reply));
|
||||||
// If reply looks like a recipe, append "Usa ingredienti" button
|
// If reply looks like a recipe, append "Trasferisci a Ricette" button
|
||||||
if (_looksLikeRecipe(result.reply)) {
|
if (_looksLikeRecipe(result.reply)) {
|
||||||
const replyText = result.reply;
|
const replyText = result.reply;
|
||||||
const useBtn = document.createElement('button');
|
const transferBtn = document.createElement('button');
|
||||||
useBtn.className = 'btn-chat-use-recipe';
|
transferBtn.className = 'btn-chat-use-recipe';
|
||||||
useBtn.textContent = '🥄 ' + (t('chat.use_ingredients_btn') || 'Usa ingredienti');
|
transferBtn.textContent = '📥 ' + (t('chat.transfer_to_recipes') || 'Trasferisci a Ricette');
|
||||||
useBtn.onclick = () => chatExtractIngredients(useBtn, replyText);
|
transferBtn.onclick = () => chatTransferToRecipes(transferBtn, replyText);
|
||||||
const panelDiv = document.createElement('div');
|
bubble.appendChild(transferBtn);
|
||||||
panelDiv.className = 'chat-recipe-panel-container';
|
|
||||||
panelDiv.style.display = 'none';
|
|
||||||
bubble.appendChild(useBtn);
|
|
||||||
bubble.appendChild(panelDiv);
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const errMsg = result.error === 'no_api_key' ? 'Configura la chiave API Gemini nelle impostazioni.' : (result.error || 'Errore nella risposta');
|
const errMsg = result.error === 'no_api_key' ? 'Configura la chiave API Gemini nelle impostazioni.' : (result.error || 'Errore nella risposta');
|
||||||
|
|||||||
+1
-1
@@ -1462,6 +1462,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="assets/js/app.js?v=20260510f"></script>
|
<script src="assets/js/app.js?v=20260510g"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -472,8 +472,9 @@
|
|||||||
"suggestion_juice_text": "Mach mir einen Saft oder Smoothie mit dem was ich habe",
|
"suggestion_juice_text": "Mach mir einen Saft oder Smoothie mit dem was ich habe",
|
||||||
"suggestion_light_text": "Ich habe Hunger, möchte aber etwas Leichtes",
|
"suggestion_light_text": "Ich habe Hunger, möchte aber etwas Leichtes",
|
||||||
"suggestion_expiry_text": "Was läuft bald ab und wie kann ich es verwenden?",
|
"suggestion_expiry_text": "Was läuft bald ab und wie kann ich es verwenden?",
|
||||||
"use_ingredients_btn": "Zutaten verwenden",
|
"transfer_to_recipes": "Zu Rezepten hinzufügen",
|
||||||
"recipe_ingredients_from_pantry": "Im Vorratsschrank gefundene Zutaten"
|
"transferring": "Übertrage...",
|
||||||
|
"transferred": "Zu Rezepten hinzugefügt!"
|
||||||
},
|
},
|
||||||
"cooking": {
|
"cooking": {
|
||||||
"close": "Schließen",
|
"close": "Schließen",
|
||||||
|
|||||||
@@ -472,8 +472,9 @@
|
|||||||
"suggestion_juice_text": "Make me a juice or smoothie with what I have",
|
"suggestion_juice_text": "Make me a juice or smoothie with what I have",
|
||||||
"suggestion_light_text": "I'm hungry but want something light",
|
"suggestion_light_text": "I'm hungry but want something light",
|
||||||
"suggestion_expiry_text": "What's about to expire and how can I use it?",
|
"suggestion_expiry_text": "What's about to expire and how can I use it?",
|
||||||
"use_ingredients_btn": "Use ingredients",
|
"transfer_to_recipes": "Transfer to Recipes",
|
||||||
"recipe_ingredients_from_pantry": "Ingredients found in pantry"
|
"transferring": "Transferring...",
|
||||||
|
"transferred": "Added to Recipes!"
|
||||||
},
|
},
|
||||||
"cooking": {
|
"cooking": {
|
||||||
"close": "Close",
|
"close": "Close",
|
||||||
|
|||||||
@@ -472,8 +472,9 @@
|
|||||||
"suggestion_juice_text": "Fammi un succo o frullato con quello che ho",
|
"suggestion_juice_text": "Fammi un succo o frullato con quello che ho",
|
||||||
"suggestion_light_text": "Ho fame ma voglio qualcosa di leggero",
|
"suggestion_light_text": "Ho fame ma voglio qualcosa di leggero",
|
||||||
"suggestion_expiry_text": "Cosa sta per scadere e come posso usarlo?",
|
"suggestion_expiry_text": "Cosa sta per scadere e come posso usarlo?",
|
||||||
"use_ingredients_btn": "Usa ingredienti",
|
"transfer_to_recipes": "Trasferisci a Ricette",
|
||||||
"recipe_ingredients_from_pantry": "Ingredienti trovati in dispensa"
|
"transferring": "Trasferimento in corso...",
|
||||||
|
"transferred": "Aggiunta alle Ricette!"
|
||||||
},
|
},
|
||||||
"cooking": {
|
"cooking": {
|
||||||
"close": "Chiudi",
|
"close": "Chiudi",
|
||||||
|
|||||||
Reference in New Issue
Block a user