feat(fitness): add AI Smart Macro Logger using Gemini API
This commit is contained in:
Generated
+10
@@ -8,6 +8,7 @@
|
||||
"name": "antigravity-app",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@google/generative-ai": "^0.24.1",
|
||||
"clsx": "^2.1.1",
|
||||
"framer-motion": "^12.35.0",
|
||||
"lucide-react": "^0.577.0",
|
||||
@@ -459,6 +460,15 @@
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@google/generative-ai": {
|
||||
"version": "0.24.1",
|
||||
"resolved": "https://registry.npmjs.org/@google/generative-ai/-/generative-ai-0.24.1.tgz",
|
||||
"integrity": "sha512-MqO+MLfM6kjxcKoy0p1wRzG3b4ZZXtPI+z2IE26UogS2Cm/XHO+7gGRBh6gcJsOiIVoH93UwKvW4HdgiOZCy9Q==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@humanfs/core": {
|
||||
"version": "0.19.1",
|
||||
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"lint": "eslint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@google/generative-ai": "^0.24.1",
|
||||
"clsx": "^2.1.1",
|
||||
"framer-motion": "^12.35.0",
|
||||
"lucide-react": "^0.577.0",
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { GoogleGenerativeAI } from '@google/generative-ai';
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const { mealDescription } = await req.json();
|
||||
|
||||
if (!mealDescription || typeof mealDescription !== 'string' || !mealDescription.trim()) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Please provide a valid meal description.' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const apiKey = process.env.GEMINI_API_KEY;
|
||||
if (!apiKey) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'GEMINI_API_KEY is not configured in .env.local on the server.',
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
const genAI = new GoogleGenerativeAI(apiKey);
|
||||
|
||||
// Use gemini-1.5-flash with structured JSON response
|
||||
const model = genAI.getGenerativeModel({
|
||||
model: 'gemini-1.5-flash',
|
||||
generationConfig: {
|
||||
responseMimeType: 'application/json',
|
||||
temperature: 0.2,
|
||||
},
|
||||
});
|
||||
|
||||
const prompt = `You are an expert clinical sports nutritionist and dietitian specializing in evidence-based macronutrient tracking.
|
||||
Analyze the following meal description and accurately estimate the macronutrients and total energy intake.
|
||||
|
||||
Meal description:
|
||||
"${mealDescription.trim()}"
|
||||
|
||||
Return ONLY a valid JSON object matching this schema:
|
||||
{
|
||||
"mealName": "Short descriptive title of the meal",
|
||||
"calories": number (integer total kcal),
|
||||
"protein": number (integer grams of protein),
|
||||
"carbs": number (integer grams of carbohydrates),
|
||||
"fats": number (integer grams of total fat),
|
||||
"items": [
|
||||
{
|
||||
"name": "Food item name and portion",
|
||||
"calories": number,
|
||||
"protein": number,
|
||||
"carbs": number,
|
||||
"fats": number
|
||||
}
|
||||
],
|
||||
"insight": "One concise sentence on the nutritional value or timing (e.g., 'Excellent post-workout ratio providing >3g leucine for MPS stimulus.')"
|
||||
}`;
|
||||
|
||||
const result = await model.generateContent(prompt);
|
||||
const responseText = result.response.text();
|
||||
const data = JSON.parse(responseText);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
mealName: data.mealName || 'Logged Meal',
|
||||
calories: Math.round(Number(data.calories) || 0),
|
||||
protein: Math.round(Number(data.protein) || 0),
|
||||
carbs: Math.round(Number(data.carbs) || 0),
|
||||
fats: Math.round(Number(data.fats) || 0),
|
||||
items: Array.isArray(data.items) ? data.items : [],
|
||||
insight: data.insight || 'Meal analyzed successfully.',
|
||||
},
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
console.error('Error analyzing meal with Gemini:', error);
|
||||
const message = error instanceof Error ? error.message : 'Failed to analyze meal';
|
||||
return NextResponse.json(
|
||||
{ success: false, error: message },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -19,7 +19,9 @@ import {
|
||||
TrendingUp,
|
||||
Target,
|
||||
Utensils,
|
||||
Check
|
||||
Check,
|
||||
Loader2,
|
||||
Bot
|
||||
} from 'lucide-react';
|
||||
import { WorkoutSession, DailyNutrition, MuscleRecovery, WorkoutExercise } from '@/lib/types';
|
||||
import { SCIENCE_TIPS, getFormattedDate } from '@/lib/initialData';
|
||||
@@ -95,6 +97,68 @@ export const FitnessView: React.FC<FitnessViewProps> = ({
|
||||
setTimeout(() => setIsSynced(false), 2500);
|
||||
};
|
||||
|
||||
// AI Smart Macro Logger State
|
||||
const [mealInput, setMealInput] = useState('');
|
||||
const [isAnalyzing, setIsAnalyzing] = useState(false);
|
||||
const [aiError, setAiError] = useState<string | null>(null);
|
||||
const [lastAnalyzedMeal, setLastAnalyzedMeal] = useState<{
|
||||
mealName: string;
|
||||
calories: number;
|
||||
protein: number;
|
||||
carbs: number;
|
||||
fats: number;
|
||||
items?: { name: string; calories: number; protein: number; carbs: number; fats: number }[];
|
||||
insight?: string;
|
||||
} | null>(null);
|
||||
|
||||
const sampleMealChips = [
|
||||
'3 boiled eggs, 2 rotis with dal, 1 scoop whey',
|
||||
'200g grilled chicken, 1 cup brown rice & broccoli',
|
||||
'1 scoop whey isolate with 250ml milk & 1 banana',
|
||||
'1 cup rolled oats, 1 tbsp peanut butter & Greek yogurt',
|
||||
'Paneer bhurji (150g) with 2 multigrain rotis'
|
||||
];
|
||||
|
||||
const handleAnalyzeMeal = async (customPrompt?: string) => {
|
||||
const promptToUse = customPrompt || mealInput;
|
||||
if (!promptToUse.trim() || isAnalyzing) return;
|
||||
|
||||
setIsAnalyzing(true);
|
||||
setAiError(null);
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/analyze-meal', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ mealDescription: promptToUse.trim() }),
|
||||
});
|
||||
|
||||
const json = await res.json();
|
||||
if (!res.ok || !json.success) {
|
||||
throw new Error(json.error || 'Failed to analyze meal.');
|
||||
}
|
||||
|
||||
const meal = json.data;
|
||||
setLastAnalyzedMeal(meal);
|
||||
|
||||
// Auto-fill calculated macros directly into the macro tracker!
|
||||
onUpdateNutrition({
|
||||
calories: Math.max(0, nutrition.calories + meal.calories),
|
||||
protein: Math.max(0, nutrition.protein + meal.protein),
|
||||
carbs: Math.max(0, nutrition.carbs + meal.carbs),
|
||||
fats: Math.max(0, nutrition.fats + meal.fats),
|
||||
});
|
||||
|
||||
setMealInput('');
|
||||
playNotificationChime('streak');
|
||||
} catch (err: unknown) {
|
||||
console.error('AI meal analyze error:', err);
|
||||
setAiError(err instanceof Error ? err.message : 'Error analyzing meal. Check API key or network.');
|
||||
} finally {
|
||||
setIsAnalyzing(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Log Workout Form State
|
||||
const [workoutName, setWorkoutName] = useState('Push Hypertrophy');
|
||||
const [workoutDate, setWorkoutDate] = useState(getFormattedDate(0));
|
||||
@@ -244,6 +308,168 @@ export const FitnessView: React.FC<FitnessViewProps> = ({
|
||||
<span className="text-xs text-slate-400">Bodyweight: <strong className="text-white">{nutrition.bodyweightKg} kg</strong></span>
|
||||
</div>
|
||||
|
||||
{/* AI SMART MEAL INTAKE CARD */}
|
||||
<div className="glass-card p-5 sm:p-6 border border-lime-500/30 bg-gradient-to-br from-[#0c1a17]/90 via-[#0d1424]/90 to-[#120f26]/90 relative overflow-hidden shadow-2xl space-y-4">
|
||||
<div className="absolute top-0 right-0 w-80 h-80 bg-lime-500/5 rounded-full blur-3xl pointer-events-none" />
|
||||
<div className="absolute bottom-0 left-0 w-80 h-80 bg-purple-500/5 rounded-full blur-3xl pointer-events-none" />
|
||||
|
||||
{/* Card Header */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2 relative z-10">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="p-2 rounded-xl bg-gradient-to-tr from-lime-500/20 to-emerald-500/20 border border-lime-500/40 text-lime-400">
|
||||
<Bot className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-sm sm:text-base font-bold text-white flex items-center gap-2">
|
||||
<span>AI Smart Meal Intake Logger</span>
|
||||
<span className="text-[10px] font-extrabold uppercase px-2 py-0.5 rounded-full bg-lime-500/10 text-lime-400 border border-lime-500/30">
|
||||
Gemini AI
|
||||
</span>
|
||||
</h4>
|
||||
<p className="text-xs text-slate-400">
|
||||
Describe what you ate in natural language—AI calculates the macros and updates your tracker automatically.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Meal Input and Submit Action */}
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
handleAnalyzeMeal();
|
||||
}}
|
||||
className="space-y-3 relative z-10"
|
||||
>
|
||||
<div className="flex flex-col sm:flex-row gap-2.5">
|
||||
<input
|
||||
type="text"
|
||||
value={mealInput}
|
||||
onChange={(e) => setMealInput(e.target.value)}
|
||||
disabled={isAnalyzing}
|
||||
placeholder="e.g. 3 boiled eggs, 2 rotis with dal, 1 scoop whey protein"
|
||||
className="flex-1 px-4 py-3 rounded-xl bg-slate-950/80 border border-slate-800 text-sm text-slate-100 placeholder-slate-500 focus:outline-none focus:border-lime-500 transition-colors disabled:opacity-50"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isAnalyzing || !mealInput.trim()}
|
||||
className="px-6 py-3 rounded-xl bg-gradient-to-r from-lime-500 via-emerald-500 to-teal-500 text-slate-950 font-bold text-xs shadow-lg shadow-lime-500/20 hover:scale-102 active:scale-98 transition-all disabled:opacity-40 disabled:hover:scale-100 disabled:cursor-not-allowed cursor-pointer flex items-center justify-center gap-2 shrink-0"
|
||||
>
|
||||
{isAnalyzing ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
<span>Analyzing with AI...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Sparkles className="w-4 h-4" />
|
||||
<span>Analyze Meal with AI</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Sample Prompt Chips */}
|
||||
<div className="flex flex-wrap items-center gap-1.5 pt-1">
|
||||
<span className="text-[11px] font-semibold text-slate-400 flex items-center gap-1 mr-1">
|
||||
<Sparkles className="w-3 h-3 text-lime-400" />
|
||||
Quick ideas:
|
||||
</span>
|
||||
{sampleMealChips.map((chip, idx) => (
|
||||
<button
|
||||
key={idx}
|
||||
type="button"
|
||||
disabled={isAnalyzing}
|
||||
onClick={() => {
|
||||
setMealInput(chip);
|
||||
handleAnalyzeMeal(chip);
|
||||
}}
|
||||
className="text-[11px] px-2.5 py-1 rounded-lg bg-slate-900/80 hover:bg-slate-800 border border-slate-800 hover:border-lime-500/40 text-slate-300 hover:text-lime-300 transition-all cursor-pointer text-left truncate max-w-xs sm:max-w-none disabled:opacity-40"
|
||||
>
|
||||
{chip}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* Error Banner */}
|
||||
{aiError && (
|
||||
<div className="p-3 rounded-xl bg-red-950/30 border border-red-500/40 text-xs text-red-300 flex items-center justify-between">
|
||||
<span>⚠️ {aiError}</span>
|
||||
<button
|
||||
onClick={() => setAiError(null)}
|
||||
className="text-red-400 hover:text-red-200 text-xs font-bold ml-2 cursor-pointer"
|
||||
>
|
||||
Dismiss
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Last Analyzed Meal Results Card */}
|
||||
{lastAnalyzedMeal && (
|
||||
<div className="p-4 rounded-xl bg-lime-950/20 border border-lime-500/40 space-y-3 animate-in fade-in duration-300 relative z-10">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2 border-b border-lime-500/20 pb-2.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircle2 className="w-4 h-4 text-lime-400" />
|
||||
<span className="text-xs font-bold text-white uppercase tracking-wider">
|
||||
{lastAnalyzedMeal.mealName}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-[11px] font-semibold text-emerald-400 bg-emerald-500/10 px-2 py-0.5 rounded-full border border-emerald-500/30">
|
||||
✓ Auto-filled to Today's Intake
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Added Macro Badges */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2">
|
||||
<div className="p-2 rounded-lg bg-slate-900/90 border border-lime-500/30 text-center">
|
||||
<span className="text-[10px] font-bold text-slate-400 uppercase block">Calories</span>
|
||||
<span className="text-lg font-black text-lime-400 font-mono">+{lastAnalyzedMeal.calories}</span>
|
||||
<span className="text-[10px] text-slate-500 block">kcal</span>
|
||||
</div>
|
||||
<div className="p-2 rounded-lg bg-slate-900/90 border border-cyan-500/30 text-center">
|
||||
<span className="text-[10px] font-bold text-slate-400 uppercase block">Protein</span>
|
||||
<span className="text-lg font-black text-cyan-400 font-mono">+{lastAnalyzedMeal.protein}g</span>
|
||||
<span className="text-[10px] text-slate-500 block">Leucine rich</span>
|
||||
</div>
|
||||
<div className="p-2 rounded-lg bg-slate-900/90 border border-amber-500/30 text-center">
|
||||
<span className="text-[10px] font-bold text-slate-400 uppercase block">Carbohydrates</span>
|
||||
<span className="text-lg font-black text-amber-400 font-mono">+{lastAnalyzedMeal.carbs}g</span>
|
||||
<span className="text-[10px] text-slate-500 block">Glycogen fuel</span>
|
||||
</div>
|
||||
<div className="p-2 rounded-lg bg-slate-900/90 border border-orange-500/30 text-center">
|
||||
<span className="text-[10px] font-bold text-slate-400 uppercase block">Healthy Fats</span>
|
||||
<span className="text-lg font-black text-orange-400 font-mono">+{lastAnalyzedMeal.fats}g</span>
|
||||
<span className="text-[10px] text-slate-500 block">Hormone balance</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Items Breakdown & Scientific Insight */}
|
||||
{lastAnalyzedMeal.insight && (
|
||||
<p className="text-xs text-lime-300/90 bg-slate-950/40 p-2.5 rounded-lg border border-lime-500/20 italic">
|
||||
💡 {lastAnalyzedMeal.insight}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{lastAnalyzedMeal.items && lastAnalyzedMeal.items.length > 0 && (
|
||||
<div className="pt-1">
|
||||
<span className="text-[10px] font-bold text-slate-400 uppercase tracking-wider block mb-1">
|
||||
Itemized Portion Estimates
|
||||
</span>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-1.5">
|
||||
{lastAnalyzedMeal.items.map((it, i) => (
|
||||
<div key={i} className="text-[11px] p-1.5 px-2 rounded bg-slate-900/60 border border-slate-800 text-slate-300 flex items-center justify-between">
|
||||
<span className="truncate pr-2 font-medium">{it.name}</span>
|
||||
<span className="text-slate-400 shrink-0 font-mono">{it.calories} kcal ({it.protein}g P)</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{/* Calories Card */}
|
||||
<div className="glass-card p-5 border-l-4 border-l-lime-500">
|
||||
|
||||
Reference in New Issue
Block a user