document.addEventListener('DOMContentLoaded', () => { const chatForm = document.getElementById('chat-form'); const userInput = document.getElementById('user-input'); const chatContainer = document.getElementById('chat-container'); const sendBtn = document.getElementById('send-btn'); const clearChatBtn = document.getElementById('clear-chat'); let chatHistory = []; // Store conversation history // Auto-resize textarea userInput.addEventListener('input', function() { this.style.height = 'auto'; this.style.height = (this.scrollHeight > 150 ? 150 : this.scrollHeight) + 'px'; if (this.value.trim() === '') { sendBtn.disabled = true; } else { sendBtn.disabled = false; } }); // Handle Enter key (Shift+Enter for new line) userInput.addEventListener('keydown', function(e) { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); if (!sendBtn.disabled) { chatForm.requestSubmit(); } } }); clearChatBtn.addEventListener('click', () => { if (confirm('Are you sure you want to clear the chat history?')) { chatHistory = []; chatContainer.innerHTML = ''; addMessage('system', 'Hello! I am LocalFoodAI, your completely local nutrition and menu assistant. How can I help you today?'); } }); chatForm.addEventListener('submit', async (e) => { e.preventDefault(); const message = userInput.value.trim(); if (!message) return; // Reset input userInput.value = ''; userInput.style.height = 'auto'; sendBtn.disabled = true; // Add user message to UI addMessage('user', message); chatHistory.push({ role: 'user', content: message }); // Add loading indicator const loadingId = addTypingIndicator(); try { const token = localStorage.getItem('localFoodToken'); // Fetch response from backend const response = await fetch('/chat', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` }, body: JSON.stringify({ messages: chatHistory }) }); if (response.status === 401) { setLoggedOutState(); addMessage('system', 'Your session has expired. Please log in again.'); return; } if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } // Remove loading indicator removeElement(loadingId); // Create new bot message container const botMessageId = 'msg-' + Date.now(); const botContentEl = addMessage('system', '', botMessageId); let botFullResponse = ''; // Handle Server-Sent Events (Streaming) const reader = response.body.getReader(); const decoder = new TextDecoder('utf-8'); let done = false; while (!done) { const { value, done: readerDone } = await reader.read(); done = readerDone; if (value) { const chunk = decoder.decode(value, { stream: true }); // Split the chunk by double newline to get individual SSE messages const lines = chunk.split('\n\n'); for (const line of lines) { if (line.startsWith('data: ')) { const dataStr = line.substring(6); if (dataStr.trim() === '') continue; try { const data = JSON.parse(dataStr); if (data.error) { botContentEl.innerHTML += `
Error: ${data.error}`; } else if (data.content !== undefined) { botFullResponse += data.content; // Basic text to HTML conversion botContentEl.innerHTML = formatText(botFullResponse); chatContainer.scrollTop = chatContainer.scrollHeight; } } catch (err) { console.error('Error parsing SSE data:', err, dataStr); } } } } } // Save bot response to history once complete chatHistory.push({ role: 'assistant', content: botFullResponse }); } catch (error) { console.error('Chat error:', error); removeElement(loadingId); addMessage('system', 'Sorry, there was an error communicating with the local LLM. Make sure the server and Ollama are running.'); } finally { sendBtn.disabled = false; userInput.focus(); } }); function addMessage(role, content, id = null) { const msgDiv = document.createElement('div'); msgDiv.className = `message ${role}`; if (id) msgDiv.id = id; const avatarDiv = document.createElement('div'); avatarDiv.className = 'avatar'; avatarDiv.textContent = role === 'user' ? '👤' : '🤖'; const contentDiv = document.createElement('div'); contentDiv.className = 'message-content'; contentDiv.innerHTML = formatText(content); msgDiv.appendChild(avatarDiv); msgDiv.appendChild(contentDiv); chatContainer.appendChild(msgDiv); chatContainer.scrollTop = chatContainer.scrollHeight; return contentDiv; } function addTypingIndicator() { const id = 'typing-' + Date.now(); const msgDiv = document.createElement('div'); msgDiv.className = 'message system'; msgDiv.id = id; const avatarDiv = document.createElement('div'); avatarDiv.className = 'avatar'; avatarDiv.textContent = '🤖'; const contentDiv = document.createElement('div'); contentDiv.className = 'message-content typing-indicator'; contentDiv.innerHTML = `
`; msgDiv.appendChild(avatarDiv); msgDiv.appendChild(contentDiv); chatContainer.appendChild(msgDiv); chatContainer.scrollTop = chatContainer.scrollHeight; return id; } function removeElement(id) { const el = document.getElementById(id); if (el) el.remove(); } function formatText(text) { if (!text) return ''; // Very basic markdown parsing for bold, italics, code, and newlines let formatted = text .replace(/&/g, "&") .replace(//g, ">") .replace(/\n/g, "
") .replace(/\*\*(.*?)\*\*/g, "$1") // bold .replace(/\*(.*?)\*/g, "$1") // italic .replace(/`(.*?)`/g, "$1"); // inline code return formatted; } // Authentication & Session Logic const authScreen = document.getElementById('auth-screen'); const chatApp = document.getElementById('chat-app'); const loginForm = document.getElementById('login-form'); const registerForm = document.getElementById('register-form'); const showRegisterLink = document.getElementById('show-register'); const showLoginLink = document.getElementById('show-login'); const loginError = document.getElementById('login-error'); const regError = document.getElementById('reg-error'); const regSuccess = document.getElementById('reg-success'); const logoutBtn = document.getElementById('nav-logout-btn'); const userGreeting = document.getElementById('user-greeting'); // Check session on load const savedUser = localStorage.getItem('localFoodUser'); const savedToken = localStorage.getItem('localFoodToken'); if (savedUser && savedToken) { setLoggedInState(savedUser, savedToken); } async function setLoggedInState(username, token) { localStorage.setItem('localFoodUser', username); localStorage.setItem('localFoodToken', token); userGreeting.textContent = `Welcome, ${username}`; authScreen.classList.add('fade-out'); setTimeout(() => { authScreen.style.display = 'none'; chatApp.style.display = 'flex'; chatApp.classList.add('fade-in'); userInput.focus(); }, 500); // Load persisted chat history and macro targets from the server await loadChatHistory(); await loadMacroTargets(); } async function loadChatHistory() { const token = localStorage.getItem('localFoodToken'); if (!token) return; try { const response = await fetch('/api/chat/history', { headers: { 'Authorization': `Bearer ${token}` } }); if (response.status === 401) { setLoggedOutState(); return; } if (response.ok) { const data = await response.json(); if (data.history && data.history.length > 0) { // Clear initial welcome message if we have real history chatContainer.innerHTML = ''; chatHistory = []; // Reset local state data.history.forEach(msg => { addMessage(msg.role, msg.content); chatHistory.push({ role: msg.role, content: msg.content }); }); } } } catch (err) { console.error("Failed to load chat history:", err); } } async function loadMacroTargets() { const token = localStorage.getItem('localFoodToken'); if (!token) return; const dashboard = document.getElementById('macro-dashboard'); try { const response = await fetch('/api/macros/targets', { headers: { 'Authorization': `Bearer ${token}` } }); if (response.ok) { const data = await response.json(); document.getElementById('macro-calories').textContent = `${data.calories} kcal`; document.getElementById('macro-protein').textContent = `${data.protein_g} g`; document.getElementById('macro-carbs').textContent = `${data.carbs_g} g`; document.getElementById('macro-fat').textContent = `${data.fat_g} g`; if (dashboard) dashboard.style.display = 'flex'; } } catch (err) { console.error('Failed to load macro targets:', err); } } async function setLoggedOutState() { const token = localStorage.getItem('localFoodToken'); if (token) { try { await fetch('/api/logout', { method: 'POST', headers: { 'Authorization': `Bearer ${token}` } }); } catch (err) { console.error("Error during logout:", err); } } // Clear chat memory and interface so the next user doesn't see old messages chatHistory = []; chatContainer.innerHTML = ''; addMessage('system', 'Hello! I am LocalFoodAI, your completely local nutrition and menu assistant. How can I help you today?'); localStorage.removeItem('localFoodUser'); localStorage.removeItem('localFoodToken'); chatApp.style.display = 'none'; chatApp.classList.remove('fade-in'); const dashboard = document.getElementById('macro-dashboard'); if (dashboard) dashboard.style.display = 'none'; authScreen.style.display = 'block'; setTimeout(() => { authScreen.classList.remove('fade-out'); }, 50); loginForm.reset(); registerForm.reset(); loginError.textContent = ''; regError.textContent = ''; } logoutBtn.addEventListener('click', () => { setLoggedOutState(); }); // Toggles showRegisterLink.addEventListener('click', (e) => { e.preventDefault(); loginForm.style.display = 'none'; registerForm.style.display = 'block'; }); showLoginLink.addEventListener('click', (e) => { e.preventDefault(); registerForm.style.display = 'none'; loginForm.style.display = 'block'; }); // Login Submission loginForm.addEventListener('submit', async (e) => { e.preventDefault(); loginError.textContent = ''; const submitBtn = document.getElementById('login-submit-btn'); submitBtn.disabled = true; const username = document.getElementById('login-username').value; const password = document.getElementById('login-password').value; try { const response = await fetch('/api/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username, password }) }); const data = await response.json(); if (!response.ok) { loginError.textContent = data.detail || 'Login failed.'; } else { setLoggedInState(data.username, data.token); } } catch (err) { loginError.textContent = 'Server error. Is the backend running?'; } finally { submitBtn.disabled = false; } }); // Registration Submission registerForm.addEventListener('submit', async (e) => { e.preventDefault(); regError.textContent = ''; regSuccess.textContent = ''; const submitBtn = document.getElementById('reg-submit-btn'); submitBtn.disabled = true; const username = document.getElementById('reg-username').value; const password = document.getElementById('reg-password').value; const confirmInfo = document.getElementById('reg-confirm').value; if (password !== confirmInfo) { regError.textContent = "Passwords do not match."; submitBtn.disabled = false; return; } try { const response = await fetch('/api/register', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username, password }) }); const data = await response.json(); if (!response.ok) { regError.textContent = data.detail || 'Registration failed.'; } else { regSuccess.textContent = 'Account created! Logging in...'; setTimeout(() => { setLoggedInState(data.username, data.token); }, 1000); } } catch (err) { regError.textContent = 'Server error. Please try again later.'; } finally { submitBtn.disabled = false; } }); // --- Food Search Module Logic --- const searchInput = document.getElementById('food-search-input'); const searchDropdown = document.getElementById('search-results-dropdown'); const clearSearchBtn = document.getElementById('clear-search-btn'); function debounce(func, delay) { let timeout; return function(...args) { clearTimeout(timeout); timeout = setTimeout(() => func(...args), delay); }; } const performSearch = async (query) => { if (!query) { searchDropdown.style.display = 'none'; return; } searchDropdown.style.display = 'block'; searchDropdown.innerHTML = '
Searching local database...
'; try { const token = localStorage.getItem('localFoodToken'); const response = await fetch(`/api/food/search?q=${encodeURIComponent(query)}`, { headers: { 'Authorization': `Bearer ${token}` } }); if (response.status === 401) { setLoggedOutState(); addMessage('system', 'Your session has expired. Please log in again to use the food search.'); searchDropdown.style.display = 'none'; return; } if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } const data = await response.json(); if (data.results && data.results.length > 0) { searchDropdown.innerHTML = ''; data.results.forEach(item => { const foodEl = document.createElement('div'); foodEl.className = 'food-item'; const badgeStr = item.category === "Sourced Ingredient" ? "Raw Ingredient" : item.category; foodEl.innerHTML = `
${item.name} ${badgeStr}
Cal: ${item.calories}
Pro: ${item.protein_g}g
Fat: ${item.fat_g}g
Carb: ${item.carbs_g}g
`; // Click on the header/name area auto-fills the chat foodEl.querySelector('.food-item-header').addEventListener('click', (e) => { e.stopPropagation(); userInput.value = `Can you build a meal around ${item.name} (${item.calories} cal, ${item.protein_g}g protein)?`; userInput.focus(); userInput.style.height = 'auto'; // Reset sendBtn.disabled = false; searchDropdown.style.display = 'none'; searchInput.value = ''; clearSearchBtn.style.display = 'none'; }); // Click on the details button toggles the panel foodEl.querySelector('.details-btn').addEventListener('click', (e) => { e.stopPropagation(); const panel = document.getElementById(`details-${item.id}`); toggleFoodDetails(item.id, panel, e.target); }); searchDropdown.appendChild(foodEl); }); } else { searchDropdown.innerHTML = '
No matching foods found.
'; } } catch (error) { console.error('Search error:', error); searchDropdown.innerHTML = '
Service currently unavailable. Please try again.
'; } }; const toggleFoodDetails = async (foodId, panel, btn) => { const isExpanded = panel.classList.contains('expanded'); if (isExpanded) { panel.classList.remove('expanded'); btn.textContent = '📊 Details'; return; } // If not loaded yet, fetch from API if (panel.innerHTML.trim() === '') { panel.innerHTML = '
Loading details...
'; panel.classList.add('expanded'); // Show loading state try { const token = localStorage.getItem('localFoodToken'); const response = await fetch(`/api/food/${foodId}`, { headers: { 'Authorization': `Bearer ${token}` } }); if (!response.ok) throw new Error("Failed to fetch details"); const data = await response.json(); renderFoodDetails(panel, data); } catch (err) { console.error(err); panel.innerHTML = '
Error loading details.
'; } } else { panel.classList.add('expanded'); } btn.textContent = '✖ Close'; }; const renderFoodDetails = (panel, data) => { panel.innerHTML = `
Extended Nutrition
Fiber${data.extended.fiber_g}g
Sugar${data.extended.sugar_g}g
Cholesterol${data.extended.cholesterol_mg}mg
Vitamins
Vitamin A${data.vitamins.vitamin_a_iu}IU
Vitamin C${data.vitamins.vitamin_c_mg}mg
Minerals
Calcium${data.minerals.calcium_mg}mg
Iron${data.minerals.iron_mg}mg
Potassium${data.minerals.potassium_mg}mg
Sodium${data.minerals.sodium_mg}mg
`; }; const handleSearchInput = debounce((e) => { const query = e.target.value.trim(); if (query.length > 0) { clearSearchBtn.style.display = 'block'; performSearch(query); } else { clearSearchBtn.style.display = 'none'; searchDropdown.style.display = 'none'; } }, 300); if (searchInput) { searchInput.addEventListener('input', handleSearchInput); } if (clearSearchBtn) { clearSearchBtn.addEventListener('click', () => { searchInput.value = ''; clearSearchBtn.style.display = 'none'; searchDropdown.style.display = 'none'; searchInput.focus(); }); } document.addEventListener('click', (e) => { if (!e.target.closest('#food-search-module')) { if (searchDropdown) searchDropdown.style.display = 'none'; } }); // Initialize state sendBtn.disabled = true; });