Просмотр исходного кода

Implement meal edit/update flow, chat clear history, LLM thought streaming timer, and server diagnostics scripts

FerRo988 2 недель назад
Родитель
Сommit
24b880ca93
10 измененных файлов с 329 добавлено и 50 удалено
  1. 41 7
      database.py
  2. BIN
      localfood.db
  3. 59 18
      main.py
  4. 1 3
      scratch/deploy.py
  5. 47 0
      scratch/inspect_remote.py
  6. 36 0
      scratch/run_remote_cmd.py
  7. 16 0
      scratch/test_perf.py
  8. 2 3
      start_daemon.py
  9. 2 1
      static/index.html
  10. 125 18
      static/script.js

+ 41 - 7
database.py

@@ -159,19 +159,35 @@ def save_user_meal(user_id: int, name: str, items: List[Dict[str, Any]]) -> Opti
     finally:
         if conn: conn.close()
 
-def update_user_meal(user_id: int, meal_id: int, name: str) -> bool:
-    """Update the name of a user's saved meal, verifying ownership."""
+def update_user_meal(user_id: int, meal_id: int, name: str, items: Optional[List[Dict[str, Any]]] = None) -> bool:
+    """Update a user's saved meal (name and optionally items), verifying ownership."""
     conn = None
     try:
         conn = get_db_connection()
         cursor = conn.cursor()
+        
+        # Verify ownership first
+        cursor.execute("SELECT id FROM saved_meals WHERE id = ? AND user_id = ?", (meal_id, user_id))
+        if not cursor.fetchone():
+            return False
+            
         cursor.execute(
             "UPDATE saved_meals SET name = ? WHERE id = ? AND user_id = ?",
             (name, meal_id, user_id)
         )
-        success = cursor.rowcount > 0
+        
+        if items is not None:
+            # Delete old items
+            cursor.execute("DELETE FROM meal_items WHERE meal_id = ?", (meal_id,))
+            # Insert new items
+            for item in items:
+                cursor.execute(
+                    "INSERT INTO meal_items (meal_id, food_id, amount_g) VALUES (?, ?, ?)",
+                    (meal_id, item['food_id'], item['amount_g'])
+                )
+                
         conn.commit()
-        return success
+        return True
     except Exception as e:
         logger.error(f"Error updating user meal: {e}")
         if conn: conn.rollback()
@@ -219,7 +235,7 @@ def get_user_meals(user_id: int) -> List[Dict[str, Any]]:
         # 2. For each meal, get the items and calculate totals
         for meal in meals:
             cursor.execute('''
-                SELECT mi.amount_g, f.calories, f.protein_g, f.fat_g, f.carbs_g 
+                SELECT mi.food_id as id, f.name, mi.amount_g, f.calories, f.protein_g, f.fat_g, f.carbs_g 
                 FROM meal_items mi
                 JOIN foods f ON mi.food_id = f.id
                 WHERE mi.meal_id = ?
@@ -227,6 +243,9 @@ def get_user_meals(user_id: int) -> List[Dict[str, Any]]:
             
             items = cursor.fetchall()
             
+            # Add detailed items to the dictionary for the frontend
+            meal['items'] = [dict(item) for item in items]
+            
             # Calculate totals based on weight (database stores per 100g)
             total_cals = 0.0
             total_protein = 0.0
@@ -403,16 +422,31 @@ def get_user_chat_history(user_id: int, limit: int = 50) -> list[Dict[str, Any]]
         cursor.execute('''
             SELECT role, content FROM chat_messages 
             WHERE user_id = ? 
-            ORDER BY created_at ASC 
+            ORDER BY created_at DESC 
             LIMIT ?
         ''', (user_id, limit))
         rows = cursor.fetchall()
-        return [dict(row) for row in rows]
+        return [dict(row) for row in reversed(rows)]
     except Exception as e:
         logger.error(f"Error fetching chat history: {e}")
         return []
     finally:
         if conn: conn.close()
+
+def delete_user_chat_history(user_id: int) -> bool:
+    """Clear all chat messages for a user"""
+    conn = None
+    try:
+        conn = get_db_connection()
+        cursor = conn.cursor()
+        cursor.execute("DELETE FROM chat_messages WHERE user_id = ?", (user_id,))
+        conn.commit()
+        return True
+    except Exception as e:
+        logger.error(f"Error deleting chat history: {e}")
+        return False
+    finally:
+        if conn: conn.close()
 def get_user_profile(user_id: int) -> Optional[Dict[str, Any]]:
     """Fetch the user's profile containing macro targets. Inserts defaults if none exists."""
     conn = None


+ 59 - 18
main.py

@@ -6,7 +6,7 @@ import asyncio
 from contextlib import asynccontextmanager
 from concurrent.futures import ThreadPoolExecutor
 from fastapi import FastAPI, HTTPException, Depends, Header
-from database import create_tables, create_user, get_user_by_username, create_session, get_user_from_token, delete_session, search_foods_by_name, save_chat_message, get_user_chat_history, get_user_profile, get_food_by_id, get_foods_by_ids, save_user_meal, update_user_meal, delete_user_meal, get_user_meals
+from database import create_tables, create_user, get_user_by_username, create_session, get_user_from_token, delete_session, search_foods_by_name, save_chat_message, get_user_chat_history, delete_user_chat_history, get_user_profile, get_food_by_id, get_foods_by_ids, save_user_meal, update_user_meal, delete_user_meal, get_user_meals
 from fastapi.responses import HTMLResponse, StreamingResponse
 from fastapi.staticfiles import StaticFiles
 from pydantic import BaseModel
@@ -22,7 +22,7 @@ async def lifespan(app: FastAPI):
     create_tables()
     try:
         async with httpx.AsyncClient() as client:
-            await client.post("http://localhost:11434/api/generate", 
+            await client.post("http://127.0.0.1:11434/api/generate", 
                 json={"model": MODEL_NAME, "prompt": "", "keep_alive": "10m"},
                 timeout=60.0)
         logger.info(f"Modèle {MODEL_NAME} pré-chargé en mémoire")
@@ -96,7 +96,7 @@ def extract_food_context(messages: list) -> str | None:
     if not keywords:
         return None
 
-    recipe_keywords = ["recipe", "recette", "cook", "cuisiner", "plat", "dish", "make", "prepare"]
+    recipe_keywords = ["recipe", "recette", "cook", "cuisiner", "plat", "dish", "make", "prepare", "meal", "repas", "menu"]
     is_recipe_request = any(kw in last_user_msg.lower() for kw in recipe_keywords)
 
     # Try each keyword against the local food database, collect unique results
@@ -129,15 +129,17 @@ def extract_food_context(messages: list) -> str | None:
             "2. **Total nutritional values of the dish** : a markdown table with Cal, Protein, Fat, Carbs, Fiber.",
             "3. **Recipe** : simple and clear steps.",
             "4. **Suggestions** : 0 to 2 tips such as 'if you add X, you will get more Y' or 'you can also cook X this way'.",
+            "CRITICAL: Do NOT list the nutritional values of each individual ingredient! You MUST ONLY provide the TOTAL combined nutritional values for the entire meal.",
             "Use ONLY the nutritional data provided below to calculate the final values.",
         ]
     else:
         lines += [
-            "For each food discussed, you MUST follow this structure:",
+            "STRICT INSTRUCTION: The user is asking for nutritional data. You MUST ONLY return nutritional data.",
+            "DO NOT include any recipes, cooking instructions, meal ideas, or extra commentary. This is strictly forbidden.",
+            "For each food discussed, you MUST follow this exact structure and provide NOTHING ELSE:",
             "1. Header: ### 🥗 [Name] (per 100g)",
             "2. Macros: A markdown table for Cal, P, F, C, Fib, Sug, Chol.",
             "3. Micros: A bulleted list for Na, Ca, Fe, K, VitA, VitC.",
-            "4. Insight: A 1-sentence analysis of the food's nutritional profile.",
         ]
 
     lines += [
@@ -181,6 +183,7 @@ class MealSaveRequest(BaseModel):
 
 class MealUpdateRequest(BaseModel):
     name: str
+    items: Optional[List[MealItemInput]] = None
 
 @app.get("/", response_class=HTMLResponse)
 async def read_root():
@@ -254,7 +257,7 @@ async def chat_endpoint(request: ChatRequest, current_user: dict = Depends(get_c
     """
     # Keep only the last 6 messages for context window performance on CPU
     all_messages = [msg.model_dump() for msg in request.messages]
-    messages = all_messages[-6:]
+    messages = all_messages[-3:]
     
     if messages and messages[-1]['role'] == 'user':
         asyncio.get_event_loop().run_in_executor(
@@ -294,27 +297,53 @@ async def chat_endpoint(request: ChatRequest, current_user: dict = Depends(get_c
                         yield f"data: {json.dumps({'error': f'LLM Error ({response.status_code})'})}\n\n"
                         return
 
+                    bot_full_response = ""
+                    started_thinking = False
+                    
                     async for line in response.aiter_lines():
                         if line:
                             try:
                                 data = json.loads(line)
-                                if "message" in data and "content" in data["message"]:
-                                    content = data["message"]["content"]
-                                    bot_full_response += content
-                                    yield f"data: {json.dumps({'content': content})}\n\n"
+                                msg = data.get("message", {}).get("content", "")
+                                thinking = data.get("message", {}).get("thinking", "")
+                                
+                                chunk = ""
+                                # Stream the thinking process as gray italic text
+                                if thinking:
+                                    if not started_thinking:
+                                        chunk += "<div style='color: #888; font-style: italic; font-size: 0.9em; padding-left: 10px; border-left: 3px solid #ccc; margin-bottom: 10px;'>"
+                                        started_thinking = True
+                                    
+                                    # Replace newlines with <br> for HTML streaming
+                                    chunk += thinking.replace('\n', '<br>')
+                                    
+                                if msg:
+                                    # Close the thinking div once the actual message starts
+                                    if started_thinking and not bot_full_response:
+                                        chunk += "</div><br>"
+                                        started_thinking = False
+                                    
+                                    chunk += msg
+                                    bot_full_response += msg
+                                    
+                                if chunk:
+                                    yield f"data: {json.dumps({'content': chunk, 'is_thinking': started_thinking})}\n\n"
+                                    
                                 if data.get("done"):
                                     break
                             except json.JSONDecodeError:
                                 continue
+                    
+                    yield "data: [DONE]\n\n"
             
+        except Exception as e:
+            logger.exception(f"Unexpected error during chat stream: {e}")
+            yield f"data: {json.dumps({'error': 'A technical error occurred while generating the response.'})}\n\n"
+        finally:
             if bot_full_response.strip():
                 asyncio.get_event_loop().run_in_executor(
                     executor, save_chat_message, current_user['id'], 'assistant', bot_full_response
                 )
-                
-        except Exception as e:
-            logger.exception(f"Unexpected error during chat stream: {e}")
-            yield f"data: {json.dumps({'error': 'A technical error occurred while generating the response.'})}\n\n"
 
     return StreamingResponse(generate_response(), media_type="text/event-stream")
 
@@ -324,6 +353,14 @@ async def get_history(current_user: dict = Depends(get_current_user)):
     history = get_user_chat_history(current_user['id'])
     return {"history": history}
 
+@app.delete("/api/chat/history")
+async def delete_history(current_user: dict = Depends(get_current_user)):
+    """Clear the chat history for the authenticated user"""
+    success = delete_user_chat_history(current_user['id'])
+    if not success:
+        raise HTTPException(status_code=500, detail="Failed to delete chat history")
+    return {"status": "success", "message": "Chat history cleared"}
+
 @app.get("/api/food/search")
 async def search_food(q: str, current_user: dict = Depends(get_current_user)):
     """API endpoint to search for food items securely using token authentication"""
@@ -470,15 +507,19 @@ async def save_meal(request: MealSaveRequest, current_user: dict = Depends(get_c
 
 @app.put("/api/meals/{meal_id}")
 async def rename_meal(meal_id: int, request: MealUpdateRequest, current_user: dict = Depends(get_current_user)):
-    """Securely rename a saved meal for the authenticated user"""
+    """Securely update a saved meal for the authenticated user"""
     if not request.name.strip():
         raise HTTPException(status_code=400, detail="Meal name cannot be empty")
         
-    success = update_user_meal(current_user['id'], meal_id, request.name.strip())
+    items_list = None
+    if request.items is not None:
+        items_list = [item.model_dump() for item in request.items]
+        
+    success = update_user_meal(current_user['id'], meal_id, request.name.strip(), items_list)
     if not success:
         raise HTTPException(status_code=404, detail="Meal not found or unauthorized")
         
-    return {"status": "success", "message": "Meal renamed"}
+    return {"status": "success", "message": "Meal updated"}
 
 @app.delete("/api/meals/{meal_id}")
 async def delete_meal(meal_id: int, current_user: dict = Depends(get_current_user)):
@@ -491,4 +532,4 @@ async def delete_meal(meal_id: int, current_user: dict = Depends(get_current_use
 
 if __name__ == "__main__":
     import uvicorn
-    uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True)
+    uvicorn.run("main:app", host="0.0.0.0", port=8000)

+ 1 - 3
scratch/deploy.py

@@ -22,9 +22,7 @@ def deploy():
         print("Upload complete.")
         
         cmd = """cd ~/LocalFoodAI
-pkill -f "main.py" || true
-sleep 2
-/home/roni/LocalFoodAI/venv/bin/python3 start_daemon.py
+systemctl --user restart localfood
 """
         
         cmd = cmd.replace('\r\n', '\n')

+ 47 - 0
scratch/inspect_remote.py

@@ -0,0 +1,47 @@
+import paramiko
+
+def inspect_remote():
+    host = "192.168.130.171"
+    user = "roni"
+    pw = "BTSai123"
+    
+    commands = {
+        "Directory listing": "ls -la ~/LocalFoodAI",
+        "Python processes": "ps aux | grep -i python",
+        "Port binding (8000)": "ss -tln | grep 8000 || netstat -an | grep 8000 || true",
+        "Recent logs (server.log)": "tail -n 20 ~/LocalFoodAI/server.log",
+        "Network interfaces / IP info": "ip addr show | grep -E 'inet '",
+        "UFW status": "sudo ufw status || true",
+        "System uptime & load": "uptime"
+    }
+    
+    try:
+        print(f"Connecting to remote VM at {host}...")
+        client = paramiko.SSHClient()
+        client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
+        client.connect(host, username=user, password=pw, timeout=10)
+        print("Connected successfully. Running diagnostics...\n")
+        
+        for desc, cmd in commands.items():
+            print("=" * 60)
+            print(f"DIAGNOSTIC: {desc}")
+            print(f"COMMAND: {cmd}")
+            print("=" * 60)
+            stdin, stdout, stderr = client.exec_command(cmd)
+            out = stdout.read().decode().strip()
+            err = stderr.read().decode().strip()
+            
+            if out:
+                print(out)
+            else:
+                print("[No Output]")
+            if err:
+                print(f"\n[stderr]:\n{err}")
+            print()
+            
+        client.close()
+    except Exception as e:
+        print(f"Error during inspection: {e}")
+
+if __name__ == "__main__":
+    inspect_remote()

+ 36 - 0
scratch/run_remote_cmd.py

@@ -0,0 +1,36 @@
+import paramiko
+import sys
+
+def run_cmd(cmd):
+    host = "192.168.130.171"
+    user = "roni"
+    pw = "BTSai123"
+    
+    try:
+        client = paramiko.SSHClient()
+        client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
+        client.connect(host, username=user, password=pw, timeout=10)
+        
+        stdin, stdout, stderr = client.exec_command(cmd)
+        out = stdout.read().decode()
+        err = stderr.read().decode()
+        
+        client.close()
+        return out, err
+    except Exception as e:
+        return "", f"Connection Error: {e}"
+
+if __name__ == "__main__":
+    if len(sys.argv) < 2:
+        print("Usage: py run_remote_cmd.py \"command\"")
+        sys.exit(1)
+        
+    cmd = sys.argv[1]
+    print(f"Executing remote: {cmd}")
+    out, err = run_cmd(cmd)
+    if out:
+        print("--- stdout ---")
+        print(out)
+    if err:
+        print("--- stderr ---")
+        print(err)

+ 16 - 0
scratch/test_perf.py

@@ -0,0 +1,16 @@
+import paramiko
+import sys
+
+host = '192.168.130.171'
+user = 'roni'
+pw = 'BTSai123'
+client = paramiko.SSHClient()
+client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
+try:
+    client.connect(host, username=user, password=pw)
+    cmd = """time curl -s -X POST http://127.0.0.1:11434/api/chat -d '{"model": "qwen3.5:4b", "messages": [{"role": "user", "content": "hello"}], "stream": false}'"""
+    stdin, stdout, stderr = client.exec_command(cmd)
+    print('STDOUT:', stdout.read().decode())
+    print('STDERR:', stderr.read().decode())
+finally:
+    client.close()

+ 2 - 3
start_daemon.py

@@ -16,9 +16,8 @@ def main():
         sys.exit(1)
         
     print(f"Starting main.py with daemon wrapper...")
-    with open(log_file, "a") as f:
-        # start_new_session=True detaches the child process from the SSH session
-        subprocess.Popen([venv_python, main_py], stdout=f, stderr=f, start_new_session=True, cwd="/home/roni/LocalFoodAI")
+    f = open(log_file, "a")
+    subprocess.Popen([venv_python, main_py], stdout=f, stderr=f, start_new_session=True, cwd="/home/roni/LocalFoodAI")
     print("Daemon successfully spawned main.py process.")
 
 if __name__ == "__main__":

+ 2 - 1
static/index.html

@@ -210,6 +210,7 @@
         </div>
     </div>
 
-    <script src="/static/script.js"></script>
+    <script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
+    <script src="/static/script.js?v=3"></script>
 </body>
 </html>

+ 125 - 18
static/script.js

@@ -28,8 +28,27 @@ document.addEventListener('DOMContentLoaded', () => {
         }
     });
 
-    clearChatBtn.addEventListener('click', () => {
+    clearChatBtn.addEventListener('click', async () => {
         if (confirm('Are you sure you want to clear the chat history?')) {
+            try {
+                const token = localStorage.getItem('localFoodToken');
+                console.log("Clear Chat clicked. Token exists?", !!token);
+                if (token) {
+                    console.log("Sending DELETE request to /api/chat/history...");
+                    const res = await fetch('/api/chat/history', {
+                        method: 'DELETE',
+                        headers: {
+                            'Authorization': `Bearer ${token}`
+                        }
+                    });
+                    console.log("DELETE request finished with status:", res.status);
+                } else {
+                    console.warn("No localFoodToken found in localStorage. Skipping backend deletion.");
+                }
+            } catch (err) {
+                console.error("Failed to clear chat history on server:", err);
+            }
+
             chatHistory = [];
             chatContainer.innerHTML = '';
             addMessage('system', 'Hello! I am LocalFoodAI, your completely local nutrition and menu assistant. How can I help you today?');
@@ -82,6 +101,21 @@ document.addEventListener('DOMContentLoaded', () => {
             const botMessageId = 'msg-' + Date.now();
             const botContentEl = addMessage('system', '', botMessageId);
 
+            // Add stopwatch timer for thinking phase
+            const timerSpan = document.createElement('div');
+            timerSpan.style.cssText = "font-size: 0.8em; color: #888; font-family: monospace; position: absolute; right: 15px; top: 12px;";
+            timerSpan.textContent = "0s";
+            
+            const msgDiv = document.getElementById(botMessageId);
+            msgDiv.style.position = 'relative'; 
+            msgDiv.appendChild(timerSpan);
+            
+            let seconds = 0;
+            let timerInterval = setInterval(() => {
+                seconds++;
+                timerSpan.textContent = `${seconds}s`;
+            }, 1000);
+
             let botFullResponse = '';
 
             // Handle Server-Sent Events (Streaming)
@@ -109,6 +143,13 @@ document.addEventListener('DOMContentLoaded', () => {
                                     // Basic text to HTML conversion
                                     botContentEl.innerHTML = formatText(botFullResponse);
                                     chatContainer.scrollTop = chatContainer.scrollHeight;
+
+                                    // Check if AI finished thinking
+                                    if (data.is_thinking === false && timerInterval) {
+                                        clearInterval(timerInterval);
+                                        timerInterval = null;
+                                        timerSpan.textContent = `Thought for ${seconds}s`;
+                                    }
                                 }
                             } catch (err) {
                                 console.error('Error parsing SSE data:', err, dataStr);
@@ -165,9 +206,14 @@ document.addEventListener('DOMContentLoaded', () => {
         const contentDiv = document.createElement('div');
         contentDiv.className = 'message-content typing-indicator';
         contentDiv.innerHTML = `
-            <div class="typing-dot"></div>
-            <div class="typing-dot"></div>
-            <div class="typing-dot"></div>
+            <div style="display: flex; align-items: center; gap: 8px;">
+                <div style="display: flex; gap: 3px; align-items: center; height: 20px;">
+                    <div class="typing-dot"></div>
+                    <div class="typing-dot"></div>
+                    <div class="typing-dot"></div>
+                </div>
+                <span id="${id}-timer" style="font-size: 0.85em; color: #888; font-family: monospace;">0s</span>
+            </div>
         `;
 
         msgDiv.appendChild(avatarDiv);
@@ -175,6 +221,17 @@ document.addEventListener('DOMContentLoaded', () => {
         chatContainer.appendChild(msgDiv);
         chatContainer.scrollTop = chatContainer.scrollHeight;
 
+        let seconds = 0;
+        const intervalId = setInterval(() => {
+            seconds++;
+            const span = document.getElementById(`${id}-timer`);
+            if (span) {
+                span.textContent = `${seconds}s`;
+            } else {
+                clearInterval(intervalId);
+            }
+        }, 1000);
+
         return id;
     }
 
@@ -185,15 +242,18 @@ document.addEventListener('DOMContentLoaded', () => {
 
     function formatText(text) {
         if (!text) return '';
-        // Very basic markdown parsing for bold, italics, code, and newlines
+        if (typeof marked !== 'undefined') {
+            return marked.parse(text, { breaks: true });
+        }
+        // Fallback just in case marked doesn't load
         let formatted = text
             .replace(/&/g, "&amp;")
             .replace(/</g, "&lt;")
             .replace(/>/g, "&gt;")
             .replace(/\n/g, "<br>")
-            .replace(/\*\*(.*?)\*\*/g, "<strong>$1</strong>") // bold
-            .replace(/\*(.*?)\*/g, "<em>$1</em>") // italic
-            .replace(/`(.*?)`/g, "<code style='background:rgba(255,255,255,0.1);padding:2px 4px;border-radius:4px'>$1</code>"); // inline code
+            .replace(/\*\*(.*?)\*\*/g, "<strong>$1</strong>")
+            .replace(/\*(.*?)\*/g, "<em>$1</em>")
+            .replace(/`(.*?)`/g, "<code style='background:rgba(255,255,255,0.1);padding:2px 4px;border-radius:4px'>$1</code>");
         return formatted;
     }
 
@@ -426,6 +486,7 @@ document.addEventListener('DOMContentLoaded', () => {
     
     // --- Meal Builder State & UI (US-10 Task #46) ---
     let currentMealItems = [];
+    let editingMealId = null;
     const mealBuilder = document.getElementById('meal-builder');
     const mealContent = document.getElementById('meal-content');
     const mealItemsList = document.getElementById('meal-items-list');
@@ -897,7 +958,9 @@ document.addEventListener('DOMContentLoaded', () => {
             e.stopPropagation();
             if (currentMealItems.length === 0) return;
             saveMealModal.style.display = 'flex';
-            mealNameInput.value = '';
+            if (!editingMealId) {
+                mealNameInput.value = '';
+            }
             saveMealError.textContent = '';
             mealNameInput.focus();
         });
@@ -918,12 +981,16 @@ document.addEventListener('DOMContentLoaded', () => {
             }
 
             confirmSaveBtn.disabled = true;
-            confirmSaveBtn.textContent = 'Saving...';
+            confirmSaveBtn.textContent = editingMealId ? 'Updating...' : 'Saving...';
 
             try {
                 const token = localStorage.getItem('localFoodToken');
-                const response = await fetch('/api/meals', {
-                    method: 'POST',
+                const isUpdating = !!editingMealId;
+                const method = isUpdating ? 'PUT' : 'POST';
+                const url = isUpdating ? `/api/meals/${editingMealId}` : '/api/meals';
+                
+                const response = await fetch(url, {
+                    method: method,
                     headers: {
                         'Content-Type': 'application/json',
                         'Authorization': `Bearer ${token}`
@@ -939,7 +1006,17 @@ document.addEventListener('DOMContentLoaded', () => {
 
                 if (response.ok) {
                     closeSaveModal();
-                    addMessage('system', `✅ Successfully saved "${name}" to your dashboard!`);
+                    const actionWord = isUpdating ? 'updated' : 'saved';
+                    addMessage('system', `✅ Successfully ${actionWord} "${name}"!`);
+                    
+                    if (isUpdating) {
+                        editingMealId = null;
+                        openSaveModalBtn.textContent = 'Save Meal';
+                        confirmSaveBtn.textContent = 'Save to Dashboard';
+                        currentMealItems = [];
+                        renderMealItems();
+                        calculateMealTotals();
+                    }
                     
                     // Trigger a refresh of the dashboard if it's open
                     if (window.refreshDashboard) window.refreshDashboard();
@@ -952,7 +1029,7 @@ document.addEventListener('DOMContentLoaded', () => {
                 saveMealError.textContent = 'Server error. Please try again.';
             } finally {
                 confirmSaveBtn.disabled = false;
-                confirmSaveBtn.textContent = 'Save to Dashboard';
+                confirmSaveBtn.textContent = editingMealId ? 'Update Meal' : 'Save to Dashboard';
             }
         });
     }
@@ -1058,10 +1135,7 @@ document.addEventListener('DOMContentLoaded', () => {
             // Attach listeners to new buttons
             card.querySelector('.edit-meal-btn').addEventListener('click', (e) => {
                 e.stopPropagation();
-                const newName = prompt('Enter new name for this meal:', meal.name);
-                if (newName && newName.trim() && newName !== meal.name) {
-                    renameMeal(meal.id, newName.trim());
-                }
+                editMealInBuilder(meal);
             });
 
             card.querySelector('.delete-meal-btn').addEventListener('click', (e) => {
@@ -1075,6 +1149,39 @@ document.addEventListener('DOMContentLoaded', () => {
         });
     }
 
+    function editMealInBuilder(meal) {
+        editingMealId = meal.id;
+        
+        currentMealItems = [];
+        if (meal.items && meal.items.length > 0) {
+            meal.items.forEach(item => {
+                currentMealItems.push({
+                    id: item.id,
+                    name: item.name,
+                    amount: item.amount_g,
+                    base_macros: {
+                        calories: item.calories,
+                        protein_g: item.protein_g,
+                        fat_g: item.fat_g,
+                        carbs_g: item.carbs_g
+                    }
+                });
+            });
+        }
+        
+        renderMealItems();
+        calculateMealTotals();
+        toggleDashboard(false);
+        
+        if (mealContent.classList.contains('collapsed')) {
+            toggleMealBuilder();
+        }
+        
+        mealNameInput.value = meal.name;
+        confirmSaveBtn.textContent = 'Update Meal';
+        openSaveModalBtn.textContent = 'Update Meal';
+    }
+
     async function renameMeal(id, newName) {
         try {
             const token = localStorage.getItem('localFoodToken');