script.js 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511
  1. document.addEventListener('DOMContentLoaded', () => {
  2. const chatForm = document.getElementById('chat-form');
  3. const userInput = document.getElementById('user-input');
  4. const chatContainer = document.getElementById('chat-container');
  5. const sendBtn = document.getElementById('send-btn');
  6. const clearChatBtn = document.getElementById('clear-chat');
  7. let chatHistory = []; // Store conversation history
  8. // Auto-resize textarea
  9. userInput.addEventListener('input', function() {
  10. this.style.height = 'auto';
  11. this.style.height = (this.scrollHeight > 150 ? 150 : this.scrollHeight) + 'px';
  12. if (this.value.trim() === '') {
  13. sendBtn.disabled = true;
  14. } else {
  15. sendBtn.disabled = false;
  16. }
  17. });
  18. // Handle Enter key (Shift+Enter for new line)
  19. userInput.addEventListener('keydown', function(e) {
  20. if (e.key === 'Enter' && !e.shiftKey) {
  21. e.preventDefault();
  22. if (!sendBtn.disabled) {
  23. chatForm.requestSubmit();
  24. }
  25. }
  26. });
  27. clearChatBtn.addEventListener('click', () => {
  28. if (confirm('Are you sure you want to clear the chat history?')) {
  29. chatHistory = [];
  30. chatContainer.innerHTML = '';
  31. addMessage('system', 'Hello! I am LocalFoodAI, your completely local nutrition and menu assistant. How can I help you today?');
  32. }
  33. });
  34. chatForm.addEventListener('submit', async (e) => {
  35. e.preventDefault();
  36. const message = userInput.value.trim();
  37. if (!message) return;
  38. // Reset input
  39. userInput.value = '';
  40. userInput.style.height = 'auto';
  41. sendBtn.disabled = true;
  42. // Add user message to UI
  43. addMessage('user', message);
  44. chatHistory.push({ role: 'user', content: message });
  45. // Add loading indicator
  46. const loadingId = addTypingIndicator();
  47. try {
  48. const token = localStorage.getItem('localFoodToken');
  49. // Fetch response from backend
  50. const response = await fetch('/chat', {
  51. method: 'POST',
  52. headers: {
  53. 'Content-Type': 'application/json',
  54. 'Authorization': `Bearer ${token}`
  55. },
  56. body: JSON.stringify({ messages: chatHistory })
  57. });
  58. if (response.status === 401) {
  59. setLoggedOutState();
  60. addMessage('system', 'Your session has expired. Please log in again.');
  61. return;
  62. }
  63. if (!response.ok) {
  64. throw new Error(`HTTP error! status: ${response.status}`);
  65. }
  66. // Remove loading indicator
  67. removeElement(loadingId);
  68. // Create new bot message container
  69. const botMessageId = 'msg-' + Date.now();
  70. const botContentEl = addMessage('system', '', botMessageId);
  71. let botFullResponse = '';
  72. // Handle Server-Sent Events (Streaming)
  73. const reader = response.body.getReader();
  74. const decoder = new TextDecoder('utf-8');
  75. let done = false;
  76. while (!done) {
  77. const { value, done: readerDone } = await reader.read();
  78. done = readerDone;
  79. if (value) {
  80. const chunk = decoder.decode(value, { stream: true });
  81. // Split the chunk by double newline to get individual SSE messages
  82. const lines = chunk.split('\n\n');
  83. for (const line of lines) {
  84. if (line.startsWith('data: ')) {
  85. const dataStr = line.substring(6);
  86. if (dataStr.trim() === '') continue;
  87. try {
  88. const data = JSON.parse(dataStr);
  89. if (data.error) {
  90. botContentEl.innerHTML += `<br><span style="color:#f85149">Error: ${data.error}</span>`;
  91. } else if (data.content !== undefined) {
  92. botFullResponse += data.content;
  93. // Basic text to HTML conversion
  94. botContentEl.innerHTML = formatText(botFullResponse);
  95. chatContainer.scrollTop = chatContainer.scrollHeight;
  96. }
  97. } catch (err) {
  98. console.error('Error parsing SSE data:', err, dataStr);
  99. }
  100. }
  101. }
  102. }
  103. }
  104. // Save bot response to history once complete
  105. chatHistory.push({ role: 'assistant', content: botFullResponse });
  106. } catch (error) {
  107. console.error('Chat error:', error);
  108. removeElement(loadingId);
  109. addMessage('system', 'Sorry, there was an error communicating with the local LLM. Make sure the server and Ollama are running.');
  110. } finally {
  111. sendBtn.disabled = false;
  112. userInput.focus();
  113. }
  114. });
  115. function addMessage(role, content, id = null) {
  116. const msgDiv = document.createElement('div');
  117. msgDiv.className = `message ${role}`;
  118. if (id) msgDiv.id = id;
  119. const avatarDiv = document.createElement('div');
  120. avatarDiv.className = 'avatar';
  121. avatarDiv.textContent = role === 'user' ? '👤' : '🤖';
  122. const contentDiv = document.createElement('div');
  123. contentDiv.className = 'message-content';
  124. contentDiv.innerHTML = formatText(content);
  125. msgDiv.appendChild(avatarDiv);
  126. msgDiv.appendChild(contentDiv);
  127. chatContainer.appendChild(msgDiv);
  128. chatContainer.scrollTop = chatContainer.scrollHeight;
  129. return contentDiv;
  130. }
  131. function addTypingIndicator() {
  132. const id = 'typing-' + Date.now();
  133. const msgDiv = document.createElement('div');
  134. msgDiv.className = 'message system';
  135. msgDiv.id = id;
  136. const avatarDiv = document.createElement('div');
  137. avatarDiv.className = 'avatar';
  138. avatarDiv.textContent = '🤖';
  139. const contentDiv = document.createElement('div');
  140. contentDiv.className = 'message-content typing-indicator';
  141. contentDiv.innerHTML = `
  142. <div class="typing-dot"></div>
  143. <div class="typing-dot"></div>
  144. <div class="typing-dot"></div>
  145. `;
  146. msgDiv.appendChild(avatarDiv);
  147. msgDiv.appendChild(contentDiv);
  148. chatContainer.appendChild(msgDiv);
  149. chatContainer.scrollTop = chatContainer.scrollHeight;
  150. return id;
  151. }
  152. function removeElement(id) {
  153. const el = document.getElementById(id);
  154. if (el) el.remove();
  155. }
  156. function formatText(text) {
  157. if (!text) return '';
  158. // Very basic markdown parsing for bold, italics, code, and newlines
  159. let formatted = text
  160. .replace(/&/g, "&amp;")
  161. .replace(/</g, "&lt;")
  162. .replace(/>/g, "&gt;")
  163. .replace(/\n/g, "<br>")
  164. .replace(/\*\*(.*?)\*\*/g, "<strong>$1</strong>") // bold
  165. .replace(/\*(.*?)\*/g, "<em>$1</em>") // italic
  166. .replace(/`(.*?)`/g, "<code style='background:rgba(255,255,255,0.1);padding:2px 4px;border-radius:4px'>$1</code>"); // inline code
  167. return formatted;
  168. }
  169. // Authentication & Session Logic
  170. const authScreen = document.getElementById('auth-screen');
  171. const chatApp = document.getElementById('chat-app');
  172. const loginForm = document.getElementById('login-form');
  173. const registerForm = document.getElementById('register-form');
  174. const showRegisterLink = document.getElementById('show-register');
  175. const showLoginLink = document.getElementById('show-login');
  176. const loginError = document.getElementById('login-error');
  177. const regError = document.getElementById('reg-error');
  178. const regSuccess = document.getElementById('reg-success');
  179. const logoutBtn = document.getElementById('nav-logout-btn');
  180. const userGreeting = document.getElementById('user-greeting');
  181. // Check session on load
  182. const savedUser = localStorage.getItem('localFoodUser');
  183. const savedToken = localStorage.getItem('localFoodToken');
  184. if (savedUser && savedToken) {
  185. setLoggedInState(savedUser, savedToken);
  186. }
  187. async function setLoggedInState(username, token) {
  188. localStorage.setItem('localFoodUser', username);
  189. localStorage.setItem('localFoodToken', token);
  190. userGreeting.textContent = `Welcome, ${username}`;
  191. authScreen.classList.add('fade-out');
  192. setTimeout(() => {
  193. authScreen.style.display = 'none';
  194. chatApp.style.display = 'flex';
  195. chatApp.classList.add('fade-in');
  196. userInput.focus();
  197. }, 500);
  198. // Load persisted chat history from the server
  199. await loadChatHistory();
  200. }
  201. async function loadChatHistory() {
  202. const token = localStorage.getItem('localFoodToken');
  203. if (!token) return;
  204. try {
  205. const response = await fetch('/api/chat/history', {
  206. headers: { 'Authorization': `Bearer ${token}` }
  207. });
  208. if (response.status === 401) {
  209. setLoggedOutState();
  210. return;
  211. }
  212. if (response.ok) {
  213. const data = await response.json();
  214. if (data.history && data.history.length > 0) {
  215. // Clear initial welcome message if we have real history
  216. chatContainer.innerHTML = '';
  217. chatHistory = []; // Reset local state
  218. data.history.forEach(msg => {
  219. addMessage(msg.role, msg.content);
  220. chatHistory.push({ role: msg.role, content: msg.content });
  221. });
  222. }
  223. }
  224. } catch (err) {
  225. console.error("Failed to load chat history:", err);
  226. }
  227. }
  228. async function setLoggedOutState() {
  229. const token = localStorage.getItem('localFoodToken');
  230. if (token) {
  231. try {
  232. await fetch('/api/logout', {
  233. method: 'POST',
  234. headers: { 'Authorization': `Bearer ${token}` }
  235. });
  236. } catch (err) {
  237. console.error("Error during logout:", err);
  238. }
  239. }
  240. // Clear chat memory and interface so the next user doesn't see old messages
  241. chatHistory = [];
  242. chatContainer.innerHTML = '';
  243. addMessage('system', 'Hello! I am LocalFoodAI, your completely local nutrition and menu assistant. How can I help you today?');
  244. localStorage.removeItem('localFoodUser');
  245. localStorage.removeItem('localFoodToken');
  246. chatApp.style.display = 'none';
  247. chatApp.classList.remove('fade-in');
  248. authScreen.style.display = 'block';
  249. setTimeout(() => {
  250. authScreen.classList.remove('fade-out');
  251. }, 50);
  252. loginForm.reset();
  253. registerForm.reset();
  254. loginError.textContent = '';
  255. regError.textContent = '';
  256. }
  257. logoutBtn.addEventListener('click', () => {
  258. setLoggedOutState();
  259. });
  260. // Toggles
  261. showRegisterLink.addEventListener('click', (e) => {
  262. e.preventDefault();
  263. loginForm.style.display = 'none';
  264. registerForm.style.display = 'block';
  265. });
  266. showLoginLink.addEventListener('click', (e) => {
  267. e.preventDefault();
  268. registerForm.style.display = 'none';
  269. loginForm.style.display = 'block';
  270. });
  271. // Login Submission
  272. loginForm.addEventListener('submit', async (e) => {
  273. e.preventDefault();
  274. loginError.textContent = '';
  275. const submitBtn = document.getElementById('login-submit-btn');
  276. submitBtn.disabled = true;
  277. const username = document.getElementById('login-username').value;
  278. const password = document.getElementById('login-password').value;
  279. try {
  280. const response = await fetch('/api/login', {
  281. method: 'POST',
  282. headers: { 'Content-Type': 'application/json' },
  283. body: JSON.stringify({ username, password })
  284. });
  285. const data = await response.json();
  286. if (!response.ok) {
  287. loginError.textContent = data.detail || 'Login failed.';
  288. } else {
  289. setLoggedInState(data.username, data.token);
  290. }
  291. } catch (err) {
  292. loginError.textContent = 'Server error. Is the backend running?';
  293. } finally {
  294. submitBtn.disabled = false;
  295. }
  296. });
  297. // Registration Submission
  298. registerForm.addEventListener('submit', async (e) => {
  299. e.preventDefault();
  300. regError.textContent = '';
  301. regSuccess.textContent = '';
  302. const submitBtn = document.getElementById('reg-submit-btn');
  303. submitBtn.disabled = true;
  304. const username = document.getElementById('reg-username').value;
  305. const password = document.getElementById('reg-password').value;
  306. const confirmInfo = document.getElementById('reg-confirm').value;
  307. if (password !== confirmInfo) {
  308. regError.textContent = "Passwords do not match.";
  309. submitBtn.disabled = false;
  310. return;
  311. }
  312. try {
  313. const response = await fetch('/api/register', {
  314. method: 'POST',
  315. headers: { 'Content-Type': 'application/json' },
  316. body: JSON.stringify({ username, password })
  317. });
  318. const data = await response.json();
  319. if (!response.ok) {
  320. regError.textContent = data.detail || 'Registration failed.';
  321. } else {
  322. regSuccess.textContent = 'Account created! Logging in...';
  323. setTimeout(() => {
  324. setLoggedInState(data.username, data.token);
  325. }, 1000);
  326. }
  327. } catch (err) {
  328. regError.textContent = 'Server error. Please try again later.';
  329. } finally {
  330. submitBtn.disabled = false;
  331. }
  332. });
  333. // --- Food Search Module Logic ---
  334. const searchInput = document.getElementById('food-search-input');
  335. const searchDropdown = document.getElementById('search-results-dropdown');
  336. const clearSearchBtn = document.getElementById('clear-search-btn');
  337. function debounce(func, delay) {
  338. let timeout;
  339. return function(...args) {
  340. clearTimeout(timeout);
  341. timeout = setTimeout(() => func(...args), delay);
  342. };
  343. }
  344. const performSearch = async (query) => {
  345. if (!query) {
  346. searchDropdown.style.display = 'none';
  347. return;
  348. }
  349. searchDropdown.style.display = 'block';
  350. searchDropdown.innerHTML = '<div class="search-loading">Searching local database...</div>';
  351. try {
  352. const token = localStorage.getItem('localFoodToken');
  353. const response = await fetch(`/api/food/search?q=${encodeURIComponent(query)}`, {
  354. headers: { 'Authorization': `Bearer ${token}` }
  355. });
  356. if (response.status === 401) {
  357. setLoggedOutState();
  358. addMessage('system', 'Your session has expired. Please log in again to use the food search.');
  359. searchDropdown.style.display = 'none';
  360. return;
  361. }
  362. if (!response.ok) {
  363. throw new Error(`HTTP error! status: ${response.status}`);
  364. }
  365. const data = await response.json();
  366. if (data.results && data.results.length > 0) {
  367. searchDropdown.innerHTML = '';
  368. data.results.forEach(item => {
  369. const foodEl = document.createElement('div');
  370. foodEl.className = 'food-item';
  371. const badgeStr = item.category === "Sourced Ingredient" ? "Raw Ingredient" : item.category;
  372. foodEl.innerHTML = `
  373. <div class="food-item-header">
  374. <span class="food-name">${item.name}</span>
  375. <span class="food-badge">${badgeStr}</span>
  376. </div>
  377. <div class="food-macros">
  378. <div class="macro-tag">Cal: <span>${item.calories}</span></div>
  379. <div class="macro-tag">Pro: <span>${item.protein_g}g</span></div>
  380. <div class="macro-tag">Fat: <span>${item.fat_g}g</span></div>
  381. <div class="macro-tag">Carb: <span>${item.carbs_g}g</span></div>
  382. </div>
  383. `;
  384. foodEl.addEventListener('click', () => {
  385. userInput.value = `Can you build a meal around ${item.name} (${item.calories} cal, ${item.protein_g}g protein)?`;
  386. userInput.focus();
  387. userInput.style.height = 'auto'; // Reset
  388. sendBtn.disabled = false;
  389. searchDropdown.style.display = 'none';
  390. searchInput.value = '';
  391. clearSearchBtn.style.display = 'none';
  392. });
  393. searchDropdown.appendChild(foodEl);
  394. });
  395. } else {
  396. searchDropdown.innerHTML = '<div class="search-empty">No matching foods found.</div>';
  397. }
  398. } catch (error) {
  399. console.error('Search error:', error);
  400. searchDropdown.innerHTML = '<div class="search-empty">Service currently unavailable. Please try again.</div>';
  401. }
  402. };
  403. const handleSearchInput = debounce((e) => {
  404. const query = e.target.value.trim();
  405. if (query.length > 0) {
  406. clearSearchBtn.style.display = 'block';
  407. performSearch(query);
  408. } else {
  409. clearSearchBtn.style.display = 'none';
  410. searchDropdown.style.display = 'none';
  411. }
  412. }, 300);
  413. if (searchInput) {
  414. searchInput.addEventListener('input', handleSearchInput);
  415. }
  416. if (clearSearchBtn) {
  417. clearSearchBtn.addEventListener('click', () => {
  418. searchInput.value = '';
  419. clearSearchBtn.style.display = 'none';
  420. searchDropdown.style.display = 'none';
  421. searchInput.focus();
  422. });
  423. }
  424. document.addEventListener('click', (e) => {
  425. if (!e.target.closest('#food-search-module')) {
  426. if (searchDropdown) searchDropdown.style.display = 'none';
  427. }
  428. });
  429. // Initialize state
  430. sendBtn.disabled = true;
  431. });