From 5948d976a88de9875d7cd4819b6fd8833db178a1 Mon Sep 17 00:00:00 2001 From: Ricardo Campos Date: Wed, 3 Dec 2025 17:38:29 -0300 Subject: [PATCH] feat: add AI info --- .github/workflows/deploy.yml | 1 + index.html | 48 ++++++++++++ package-lock.json | 49 ++++++++++++ package.json | 1 + src/claudeService.ts | 139 ++++++++++++++++++++++++++++++++++ src/index.ts | 95 +++++++++++++++++++++++- style.css | 140 +++++++++++++++++++++++++++++++++++ 7 files changed, 469 insertions(+), 4 deletions(-) create mode 100644 src/claudeService.ts diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 4327de1..33eec2f 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -22,6 +22,7 @@ jobs: VITE_APPWRITE_USERSETTINGSID: ${{ secrets.VITE_APPWRITE_USERSETTINGSID }} VITE_APPWRITE_MONTLYCALORIESID: ${{ secrets.VITE_APPWRITE_MONTLYCALORIESID }} VITE_APPWRITE_SHAREDDAYSID: ${{ secrets.VITE_APPWRITE_SHAREDDAYSID }} + VITE_CLAUDE_API_KEY: ${{ secrets.VITE_CLAUDE_API_KEY }} steps: - name: Checkout diff --git a/index.html b/index.html index 79e5732..b88b1e3 100644 --- a/index.html +++ b/index.html @@ -290,6 +290,54 @@ + + +
Food Log
diff --git a/package-lock.json b/package-lock.json index 98b06b8..8952ebe 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "1.0.0", "license": "ISC", "dependencies": { + "@anthropic-ai/sdk": "^0.71.0", "appwrite": "^21.5.0", "sweetalert": "^2.1.2" }, @@ -17,6 +18,35 @@ "vite": "^7.2.6" } }, + "node_modules/@anthropic-ai/sdk": { + "version": "0.71.0", + "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.71.0.tgz", + "integrity": "sha512-go1XeWXmpxuiTkosSXpb8tokLk2ZLkIRcXpbWVwJM6gH5OBtHOVsfPfGuqI1oW7RRt4qc59EmYbrXRZ0Ng06Jw==", + "license": "MIT", + "dependencies": { + "json-schema-to-ts": "^3.1.1" + }, + "bin": { + "anthropic-ai-sdk": "bin/cli" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } + } + }, + "node_modules/@babel/runtime": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz", + "integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.25.6", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.6.tgz", @@ -833,6 +863,19 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/json-schema-to-ts": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz", + "integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "ts-algebra": "^2.0.0" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/nanoid": { "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", @@ -984,6 +1027,12 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/ts-algebra": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz", + "integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==", + "license": "MIT" + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", diff --git a/package.json b/package.json index 74f44e4..1314b38 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "vite": "^7.2.6" }, "dependencies": { + "@anthropic-ai/sdk": "^0.71.0", "appwrite": "^21.5.0", "sweetalert": "^2.1.2" } diff --git a/src/claudeService.ts b/src/claudeService.ts new file mode 100644 index 0000000..d995cdc --- /dev/null +++ b/src/claudeService.ts @@ -0,0 +1,139 @@ +import Anthropic from '@anthropic-ai/sdk'; + +const API_KEY = import.meta.env.VITE_CLAUDE_API_KEY; + +// Initialize Claude client +const anthropic = new Anthropic({ + apiKey: API_KEY, + dangerouslyAllowBrowser: true // Allow usage in browser (for development/demo purposes) +}); + +export interface NutritionInfo { + vitamins: string[]; + minerals: string[]; + benefits: string[]; + notes: string; +} + +// In-memory cache for nutrition info +// Key format: "foodName_grams" (e.g., "apple_100", "chicken breast_150") +const nutritionCache = new Map(); + +/** + * Generate cache key from food name and grams + */ +function getCacheKey(foodName: string, grams: number): string { + return `${foodName.toLowerCase().trim()}_${grams}`; +} + +/** + * Get cached nutrition info if available + */ +function getCachedNutrition(foodName: string, grams: number): NutritionInfo | null { + const key = getCacheKey(foodName, grams); + return nutritionCache.get(key) || null; +} + +/** + * Store nutrition info in cache + */ +function cacheNutrition(foodName: string, grams: number, info: NutritionInfo): void { + const key = getCacheKey(foodName, grams); + nutritionCache.set(key, info); + console.log(`Cached nutrition info for: ${key} (total cached: ${nutritionCache.size})`); +} + +/** + * Get detailed nutritional information for a food item using Claude AI + * Uses in-memory cache to avoid repeated API calls for the same food/amount + * @param foodName - The name of the food item + * @param grams - Amount in grams + * @returns Promise with nutritional information + */ +export async function getNutritionInfo(foodName: string, grams: number): Promise { + // Check cache first + const cached = getCachedNutrition(foodName, grams); + if (cached) { + console.log(`Using cached nutrition info for: ${foodName} (${grams}g)`); + return cached; + } + + console.log(`Fetching nutrition info from AI for: ${foodName} (${grams}g)`); + + try { + const message = await anthropic.messages.create({ + model: 'claude-haiku-4-5-20251001', + max_tokens: 1024, + messages: [ + { + role: 'user', + content: `Provide detailed nutritional information for ${grams}g of ${foodName}. + +IMPORTANT: Respond in Brazilian Portuguese (pt-BR). + +Focus on: +1. Key vitamins (e.g., A, B complex, C, D, E, K) +2. Important minerals (e.g., iron, calcium, magnesium, zinc, potassium) +3. Main health benefits +4. Any important notes or considerations + +Format your response as JSON with this structure: +{ + "vitamins": ["Vitamina A: descrição", "Vitamina C: descrição", ...], + "minerals": ["Ferro: descrição", "Cálcio: descrição", ...], + "benefits": ["benefício 1", "benefício 2", ...], + "notes": "Informações adicionais importantes" +} + +Be concise but informative. Only include significant amounts of vitamins and minerals. All text must be in Brazilian Portuguese.` + } + ] + }); + + // Extract the text content from Claude's response + const textContent = message.content.find(block => block.type === 'text'); + if (!textContent || textContent.type !== 'text') { + throw new Error('No text content in response'); + } + + // Parse the JSON response + const responseText = textContent.text; + + // Extract JSON from the response (handle cases where Claude adds markdown formatting) + const jsonMatch = responseText.match(/\{[\s\S]*\}/); + if (!jsonMatch) { + throw new Error('Could not parse JSON from response'); + } + + const nutritionInfo: NutritionInfo = JSON.parse(jsonMatch[0]); + + // Cache the result for future use + cacheNutrition(foodName, grams, nutritionInfo); + + return nutritionInfo; + + } catch (error) { + console.error('Error getting nutrition info from Claude:', error); + throw new Error('Failed to get nutrition information. Please try again.'); + } +} + +/** + * Clear the nutrition info cache (optional utility function) + * Useful for testing or if you want to force fresh API calls + */ +export function clearNutritionCache(): void { + const size = nutritionCache.size; + nutritionCache.clear(); + console.log(`Cleared nutrition cache (${size} entries removed)`); +} + +/** + * Get cache statistics + */ +export function getCacheStats(): { size: number; keys: string[] } { + return { + size: nutritionCache.size, + keys: Array.from(nutritionCache.keys()) + }; +} diff --git a/src/index.ts b/src/index.ts index 864ede4..422f637 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,6 +6,7 @@ import swal from 'sweetalert'; import { closeMobileMenu, delay, getCleanName, getIcon, handleMobileCalendarClick, hideLoading, hideSearchResults, navigateResultsKeyboard, QUICK_DELAY, scrollToCalendarView, showLoading, toggleCardHandler, toggleMobileMenu } from './Utils.ts'; import { showAuthForms, toggleAuthForms, showRegisterForm, showLoginForm, hideAuthForms, closeAuthModal } from './auth.ts'; import { appState } from "./state"; +import { getNutritionInfo, NutritionInfo } from './claudeService'; // App state let selectedDate = new Date(); @@ -278,15 +279,16 @@ function clearEditing() { getInputById('foodSearchInput').value = ''; getInputById('gramAmount').value = '100'; showFoodPreview(false); + hideAINutritionCard(); getDivById('add-foot-title').innerHTML = 'Add Food Entry'; getButtonById('add-food-btn').innerHTML = 'Add Food'; getButtonById('cancel-food-btn').style.display = 'none'; - + // Clear edit-specific hidden inputs getInputById('foodIdToUpdate').value = ''; getInputById('foodTimeToUpdate').value = ''; getInputById('foodCaloriesToUpdate').value = ''; - + selectedFood = null; } @@ -361,11 +363,17 @@ const setupEventListeners = () => { const target = e.target as HTMLInputElement; if (target.value === ''){ showFoodPreview(false); + hideAINutritionCard(); } }); getInputById('gramAmount').addEventListener('change', () => { previewCalories(); + // Reload AI nutrition info with new amount if food is selected + if (selectedFood) { + const grams = parseFloat(getInputById('gramAmount').value) || 100; + loadAINutritionInfo(selectedFood.name, grams); + } }); getButtonById('add-food-btn').addEventListener('click', () => { @@ -556,13 +564,92 @@ function displaySearchResults(results: FoodItem[]) { appState.currentHighlightIndex = -1; } +// AI Nutrition Card Functions +function showAINutritionCard() { + const card = getDivById('ai-nutrition-card'); + card.classList.remove('display-none'); + card.classList.add('display-block'); +} + +function hideAINutritionCard() { + const card = getDivById('ai-nutrition-card'); + card.classList.add('display-none'); + card.classList.remove('display-block'); +} + +function showAILoading() { + getDivById('ai-nutrition-loading').classList.remove('hidden'); + getDivById('ai-nutrition-error').classList.add('hidden'); + getDivById('ai-nutrition-content').classList.add('hidden'); +} + +function showAIError() { + getDivById('ai-nutrition-loading').classList.add('hidden'); + getDivById('ai-nutrition-error').classList.remove('hidden'); + getDivById('ai-nutrition-content').classList.add('hidden'); +} + +function showAIContent(nutritionInfo: NutritionInfo) { + getDivById('ai-nutrition-loading').classList.add('hidden'); + getDivById('ai-nutrition-error').classList.add('hidden'); + getDivById('ai-nutrition-content').classList.remove('hidden'); + + // Populate vitamins + const vitaminsList = document.getElementById('ai-vitamins-list'); + if (vitaminsList) { + vitaminsList.innerHTML = nutritionInfo.vitamins.map(v => `
  • ${v}
  • `).join(''); + } + + // Populate minerals + const mineralsList = document.getElementById('ai-minerals-list'); + if (mineralsList) { + mineralsList.innerHTML = nutritionInfo.minerals.map(m => `
  • ${m}
  • `).join(''); + } + + // Populate benefits + const benefitsList = document.getElementById('ai-benefits-list'); + if (benefitsList) { + benefitsList.innerHTML = nutritionInfo.benefits.map(b => `
  • ${b}
  • `).join(''); + } + + // Populate notes + const notesText = document.getElementById('ai-notes-text'); + const notesSection = document.getElementById('ai-notes-section'); + if (notesText && notesSection && nutritionInfo.notes) { + notesText.textContent = nutritionInfo.notes; + notesSection.classList.remove('hidden'); + } else if (notesSection) { + notesSection.classList.add('hidden'); + } +} + +async function loadAINutritionInfo(foodName: string, grams: number) { + showAINutritionCard(); + showAILoading(); + + try { + const nutritionInfo = await getNutritionInfo(foodName, grams); + showAIContent(nutritionInfo); + } catch (error) { + console.error('Error loading AI nutrition info:', error); + showAIError(); + } +} + function selectFood(food: FoodItem) { selectedFood = food; - + getInputById('foodSearchInput').value = food.name; - hideSearchResults(); + hideSearchResults(); getInputById('gramAmount').focus(); previewCalories(food); + + // Show cancel button so user can clear selection + getButtonById('cancel-food-btn').style.display = 'inline-block'; + + // Load AI nutrition info for the selected food + const grams = parseFloat(getInputById('gramAmount').value) || 100; + loadAINutritionInfo(food.name, grams); } async function setFoodToEdit(foodId: string) { diff --git a/style.css b/style.css index e06dd5d..67ebb2e 100644 --- a/style.css +++ b/style.css @@ -490,6 +490,7 @@ label { font-weight: 600; transition: all 0.3s ease; height: fit-content; + display: none; /* Hidden by default, shown when editing */ } .cancel-btn:hover { @@ -901,3 +902,142 @@ label { pointer-events: none; cursor: not-allowed !important; } + +/* AI Nutrition Info Card */ +#ai-nutrition-card { + margin-top: 20px; + animation: fadeIn 0.3s ease-in; +} + +.ai-nutrition-container { + background: rgba(40, 40, 40, 0.6); + backdrop-filter: blur(10px); + border: 1px solid rgba(107, 141, 214, 0.3); + border-radius: 12px; + padding: 25px; +} + +.ai-nutrition-title { + font-size: 1.2rem; + font-weight: 600; + color: white; + margin: 0 0 20px 0; + display: flex; + align-items: center; + gap: 10px; +} + +.ai-badge { + background: linear-gradient(135deg, #6B8DD6 0%, #9B7BC6 100%); + color: white; + padding: 4px 10px; + border-radius: 6px; + font-size: 0.75rem; + font-weight: 700; + letter-spacing: 0.5px; +} + +/* Loading State */ +.ai-loading { + text-align: center; + padding: 30px 20px; +} + +.ai-spinner { + width: 40px; + height: 40px; + margin: 0 auto 15px; + border: 3px solid rgba(107, 141, 214, 0.3); + border-top: 3px solid #6B8DD6; + border-radius: 50%; + animation: spin 1s linear infinite; +} + +.ai-loading p { + color: rgba(255, 255, 255, 0.7); + font-size: 0.95rem; +} + +/* Error State */ +.ai-error { + text-align: center; + padding: 20px; + color: rgba(255, 107, 107, 0.9); + font-size: 0.95rem; +} + +/* Content */ +.ai-nutrition-content { + display: flex; + flex-direction: column; + gap: 20px; +} + +.ai-nutrition-section { + background: rgba(30, 30, 30, 0.4); + border-radius: 8px; + padding: 15px; +} + +.ai-section-title { + font-size: 1rem; + font-weight: 600; + color: #6B8DD6; + margin: 0 0 12px 0; + padding-bottom: 8px; + border-bottom: 1px solid rgba(107, 141, 214, 0.2); +} + +.ai-list { + list-style: none; + padding: 0; + margin: 0; + display: flex; + flex-direction: column; + gap: 8px; +} + +.ai-list li { + color: rgba(255, 255, 255, 0.85); + font-size: 0.9rem; + line-height: 1.5; + padding-left: 20px; + position: relative; +} + +.ai-list li:before { + content: "•"; + position: absolute; + left: 8px; + color: #6B8DD6; + font-weight: bold; +} + +.ai-notes { + color: rgba(255, 255, 255, 0.85); + font-size: 0.9rem; + line-height: 1.6; + margin: 0; +} + +/* Responsive adjustments */ +@media (max-width: 768px) { + .ai-nutrition-container { + padding: 20px; + } + + .ai-nutrition-title { + font-size: 1.1rem; + flex-direction: column; + align-items: flex-start; + gap: 8px; + } + + .ai-list li { + font-size: 0.85rem; + } + + .ai-notes { + font-size: 0.85rem; + } +}