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 = `
$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');
// --- Meal Builder State & UI (US-10 Task #46) ---
let currentMealItems = [];
const mealBuilder = document.getElementById('meal-builder');
const mealContent = document.getElementById('meal-content');
const mealItemsList = document.getElementById('meal-items-list');
const mealItemCount = document.getElementById('meal-item-count');
const toggleMealBtn = document.getElementById('toggle-meal-btn');
const emptyMealMsg = document.getElementById('empty-meal-msg');
const mealBuilderFooter = document.getElementById('meal-builder-footer');
const generateRecipeBtn = document.getElementById('generate-recipe-btn');
const toggleMealBuilder = () => {
const isCollapsed = mealContent.classList.contains('collapsed');
if (isCollapsed) {
mealContent.classList.remove('collapsed');
toggleMealBtn.style.transform = 'rotate(180deg)';
} else {
mealContent.classList.add('collapsed');
toggleMealBtn.style.transform = 'rotate(0deg)';
}
};
const addItemToMeal = (food) => {
// Duplicate Handling: Merge by updating grams
const existingItem = currentMealItems.find(item => item.id === food.id);
if (existingItem) {
existingItem.amount += 100;
// Directly update the DOM input for this item to avoid re-rendering the whole list
const inputEl = document.querySelector(`.meal-weight-input[data-id="${food.id}"]`);
if (inputEl) {
inputEl.value = existingItem.amount;
// Animate the row slightly to show it was updated
const rowEl = inputEl.closest('.meal-item-row');
if (rowEl) {
rowEl.style.transform = 'scale(1.02)';
rowEl.style.background = 'rgba(255, 255, 255, 0.1)';
setTimeout(() => {
rowEl.style.transform = '';
rowEl.style.background = 'rgba(255, 255, 255, 0.05)';
}, 200);
}
}
} else {
currentMealItems.push({
id: food.id,
name: food.name,
amount: 100,
base_macros: {
calories: food.calories,
protein_g: food.protein_g,
fat_g: food.fat_g,
carbs_g: food.carbs_g
}
});
renderMealItems(); // Only re-render if it's a new item
}
// Ensure builder is expanded when adding an item
if (mealContent.classList.contains('collapsed')) {
toggleMealBuilder();
}
// Trigger calculation
calculateMealTotals();
};
const removeItemFromMeal = (id) => {
currentMealItems = currentMealItems.filter(item => item.id !== id);
renderMealItems();
calculateMealTotals();
};
let calculateAbortController = null;
const calculateMealTotals = async () => {
const activeItems = currentMealItems.filter(item => item.amount > 0);
// DOM Elements
const totalsBanner = document.getElementById('meal-totals-banner');
const totalWeightEl = document.getElementById('meal-total-weight');
const totalCalEl = document.getElementById('meal-total-cal');
const totalProEl = document.getElementById('meal-total-pro');
const totalFatEl = document.getElementById('meal-total-fat');
const totalCarbEl = document.getElementById('meal-total-carb');
if (activeItems.length === 0) {
if (totalsBanner) totalsBanner.classList.remove('has-error');
if (totalWeightEl) totalWeightEl.textContent = '0g';
if (totalCalEl) totalCalEl.textContent = '0';
if (totalProEl) totalProEl.textContent = '0';
if (totalFatEl) totalFatEl.textContent = '0';
if (totalCarbEl) totalCarbEl.textContent = '0';
const saveBtn = document.getElementById('save-meal-btn');
if (saveBtn) saveBtn.style.display = 'none';
return;
}
// Race Condition Protection: Abort previous request
if (calculateAbortController) {
calculateAbortController.abort();
}
calculateAbortController = new AbortController();
try {
const token = localStorage.getItem('localFoodToken');
const payload = {
items: activeItems.map(item => ({
food_id: item.id,
amount_g: item.amount
}))
};
const response = await fetch('/api/meal/calculate', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify(payload),
signal: calculateAbortController.signal
});
if (response.ok) {
const data = await response.json();
if (totalsBanner) totalsBanner.classList.remove('has-error');
// Update UI with animation for "alive" feel
if (totalWeightEl) totalWeightEl.textContent = `${data.total_weight_g}g`;
if (totalCalEl) animateValue(totalCalEl, data.macros.calories);
if (totalProEl) animateValue(totalProEl, data.macros.protein_g);
if (totalFatEl) animateValue(totalFatEl, data.macros.fat_g);
if (totalCarbEl) animateValue(totalCarbEl, data.macros.carbs_g);
// Show Save button (Sprint 8)
const saveBtn = document.getElementById('save-meal-btn');
if (saveBtn) saveBtn.style.display = 'flex';
} else {
if (totalsBanner) totalsBanner.classList.add('has-error');
}
} catch (err) {
if (err.name === 'AbortError') return; // Ignore expected aborts
console.error('Failed to calculate meal totals:', err);
if (totalsBanner) totalsBanner.classList.add('has-error');
}
};
// Helper for smooth number transitions
const animateValue = (el, targetValue) => {
const startValue = parseFloat(el.textContent) || 0;
const duration = 400;
const startTime = performance.now();
const update = (currentTime) => {
const elapsed = currentTime - startTime;
const progress = Math.min(elapsed / duration, 1);
// Ease out quad
const ease = progress * (2 - progress);
const currentValue = startValue + (targetValue - startValue) * ease;
el.textContent = targetValue % 1 === 0 ? Math.round(currentValue) : currentValue.toFixed(1);
if (progress < 1) {
requestAnimationFrame(update);
} else {
el.textContent = targetValue;
}
};
requestAnimationFrame(update);
};
const debouncedCalculate = debounce(calculateMealTotals, 300);
const updateItemAmount = (id, newAmount) => {
const item = currentMealItems.find(item => item.id === id);
if (item) {
item.amount = parseInt(newAmount) || 0;
debouncedCalculate();
}
};
const renderMealItems = () => {
mealItemsList.innerHTML = '';
mealItemCount.textContent = `${currentMealItems.length} item${currentMealItems.length !== 1 ? 's' : ''}`;
if (currentMealItems.length === 0) {
mealItemsList.appendChild(emptyMealMsg);
mealBuilderFooter.style.display = 'none';
return;
}
mealBuilderFooter.style.display = 'flex';
currentMealItems.forEach(item => {
const row = document.createElement('div');
row.className = 'meal-item-row';
row.innerHTML = `