From 1ef85d7a585339028c55f955a74516aefbe2ae95 Mon Sep 17 00:00:00 2001 From: aodulov Date: Thu, 27 Nov 2025 07:51:36 +0200 Subject: [PATCH] AI coach fixed: acquired access to context data --- App.tsx | 10 +++- components/AICoach.tsx | 12 ++-- server/src/routes/ai.ts | 90 +++++++++++++++++++++++----- services/geminiService.ts | 120 ++++++++++++++++++++++++++++++-------- 4 files changed, 186 insertions(+), 46 deletions(-) diff --git a/App.tsx b/App.tsx index 8b0e1ce..1edbeda 100644 --- a/App.tsx +++ b/App.tsx @@ -9,7 +9,7 @@ import Plans from './components/Plans'; import Login from './components/Login'; import Profile from './components/Profile'; import { TabView, WorkoutSession, WorkoutSet, WorkoutPlan, User, Language } from './types'; -import { getSessions, saveSession, deleteSession } from './services/storage'; +import { getSessions, saveSession, deleteSession, getPlans } from './services/storage'; import { getCurrentUserProfile, getMe } from './services/auth'; import { getSystemLanguage } from './services/i18n'; @@ -19,6 +19,7 @@ function App() { const [language, setLanguage] = useState('en'); const [sessions, setSessions] = useState([]); + const [plans, setPlans] = useState([]); const [activeSession, setActiveSession] = useState(null); const [activePlan, setActivePlan] = useState(null); @@ -46,9 +47,12 @@ function App() { if (currentUser) { const s = await getSessions(currentUser.id); setSessions(s); - // Profile fetch is skipped for now as it returns undefined + // Load plans + const p = await getPlans(currentUser.id); + setPlans(p); } else { setSessions([]); + setPlans([]); } }; loadSessions(); @@ -197,7 +201,7 @@ function App() { /> )} {currentTab === 'STATS' && } - {currentTab === 'AI_COACH' && } + {currentTab === 'AI_COACH' && } {currentTab === 'PROFILE' && ( = ({ history, lang }) => { +const AICoach: React.FC = ({ history, userProfile, plans, lang }) => { const [messages, setMessages] = useState([ { id: 'intro', role: 'model', text: t('ai_intro', lang) } ]); @@ -29,7 +31,7 @@ const AICoach: React.FC = ({ history, lang }) => { useEffect(() => { try { - const chat = createFitnessChat(history, lang); + const chat = createFitnessChat(history, lang, userProfile, plans); if (chat) { chatSessionRef.current = chat; } else { @@ -38,7 +40,7 @@ const AICoach: React.FC = ({ history, lang }) => { } catch (err) { setError("Failed to initialize AI"); } - }, [history, lang]); + }, [history, lang, userProfile, plans]); const scrollToBottom = () => { messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); @@ -57,7 +59,7 @@ const AICoach: React.FC = ({ history, lang }) => { setLoading(true); try { - const result: GenerateContentResponse = await chatSessionRef.current.sendMessage({ message: userMsg.text }); + const result: GenerateContentResponse = await chatSessionRef.current.sendMessage(userMsg.text); const text = result.text; const aiMsg: Message = { diff --git a/server/src/routes/ai.ts b/server/src/routes/ai.ts index b5dc6e6..cceb312 100644 --- a/server/src/routes/ai.ts +++ b/server/src/routes/ai.ts @@ -1,18 +1,30 @@ -import express from 'express'; +import express, { Request, Response, NextFunction } from 'express'; import { GoogleGenerativeAI } from '@google/generative-ai'; import jwt from 'jsonwebtoken'; +interface JwtPayload { + userId: string; + role: string; +} + +interface AuthRequest extends Request { + user?: JwtPayload; +} + const router = express.Router(); const JWT_SECRET = process.env.JWT_SECRET || 'secret'; const API_KEY = process.env.API_KEY; const MODEL_ID = 'gemini-2.0-flash'; -const authenticate = (req: any, res: any, next: any) => { +// Store chat sessions in memory (in production, use Redis or similar) +const chatSessions = new Map(); + +const authenticate = (req: AuthRequest, res: Response, next: NextFunction) => { const token = req.headers.authorization?.split(' ')[1]; if (!token) return res.status(401).json({ error: 'Unauthorized' }); try { - const decoded = jwt.verify(token, JWT_SECRET) as any; + const decoded = jwt.verify(token, JWT_SECRET) as JwtPayload; req.user = decoded; next(); } catch { @@ -22,31 +34,83 @@ const authenticate = (req: any, res: any, next: any) => { router.use(authenticate); -router.post('/chat', async (req, res) => { +router.post('/chat', async (req: AuthRequest, res: Response) => { try { - const { history, message } = req.body; + const { systemInstruction, userMessage, sessionId } = req.body; if (!API_KEY) { return res.status(500).json({ error: 'AI service not configured' }); } + if (!userMessage) { + return res.status(400).json({ error: 'User message is required' }); + } + + if (!req.user) { + return res.status(401).json({ error: 'Unauthorized' }); + } + const ai = new GoogleGenerativeAI(API_KEY); + const userId = req.user.userId; + const chatKey = `${userId}-${sessionId || 'default'}`; - const { systemInstruction, userMessage } = req.body; + // Get or create chat session + let chat = chatSessions.get(chatKey); - const model = ai.getGenerativeModel({ - model: MODEL_ID - }); + if (!chat || systemInstruction) { + // Create new chat with system instruction + const model = ai.getGenerativeModel({ + model: MODEL_ID, + systemInstruction: systemInstruction || 'You are a helpful fitness coach.' + }); - const prompt = `${systemInstruction}\n\nUser: ${userMessage}`; - const result = await model.generateContent(prompt); + chat = model.startChat({ + history: [] + }); + + chatSessions.set(chatKey, chat); + } + + // Send message and get response + const result = await chat.sendMessage(userMessage); const response = result.response.text(); res.json({ response }); + } catch (error: any) { + console.error('AI Chat Error:', error); + + // Provide more detailed error messages + let errorMessage = 'Failed to generate AI response'; + if (error.message?.includes('API key')) { + errorMessage = 'AI service authentication failed'; + } else if (error.message?.includes('quota')) { + errorMessage = 'AI service quota exceeded'; + } else if (error.message) { + errorMessage = error.message; + } + + res.status(500).json({ error: errorMessage }); + } +}); + +// Clear chat session endpoint +router.delete('/chat/:sessionId', async (req: AuthRequest, res: Response) => { + try { + if (!req.user) { + return res.status(401).json({ error: 'Unauthorized' }); + } + + const userId = req.user.userId; + const sessionId = req.params.sessionId || 'default'; + const chatKey = `${userId}-${sessionId}`; + + chatSessions.delete(chatKey); + res.json({ success: true }); } catch (error) { - console.error(error); - res.status(500).json({ error: String(error) }); + console.error('Clear chat error:', error); + res.status(500).json({ error: 'Failed to clear chat session' }); } }); export default router; + diff --git a/services/geminiService.ts b/services/geminiService.ts index 141998d..705ceaa 100644 --- a/services/geminiService.ts +++ b/services/geminiService.ts @@ -1,49 +1,119 @@ -import { WorkoutSession } from '../types'; +import { WorkoutSession, UserProfile, WorkoutPlan } from '../types'; import { api } from './api'; -export const createFitnessChat = (history: WorkoutSession[], lang: 'en' | 'ru' = 'ru'): any => { - // The original returned a Chat object. - // Now we need to return something that behaves like it or refactor the UI. - // The UI likely calls `chat.sendMessage(msg)`. - // So we return an object with `sendMessage`. +interface FitnessChatOptions { + history: WorkoutSession[]; + userProfile?: UserProfile; + plans?: WorkoutPlan[]; + lang?: 'en' | 'ru'; + sessionId?: string; +} - // Summarize data to reduce token count while keeping relevant context - const summary = history.slice(0, 10).map(s => ({ +export const createFitnessChat = ( + history: WorkoutSession[], + lang: 'en' | 'ru' = 'ru', + userProfile?: UserProfile, + plans?: WorkoutPlan[] +): any => { + // Generate a unique session ID for this chat instance + const sessionId = crypto.randomUUID(); + + // Summarize workout history + const workoutSummary = history.slice(0, 20).map(s => ({ date: new Date(s.startTime).toLocaleDateString(lang === 'ru' ? 'ru-RU' : 'en-US'), userWeight: s.userBodyWeight, - exercises: s.sets.map(set => `${set.exerciseName}: ${set.weight ? set.weight + (lang === 'ru' ? 'кг' : 'kg') : ''}${set.reps ? ' x ' + set.reps + (lang === 'ru' ? 'повт' : 'reps') : ''} ${set.distanceMeters ? set.distanceMeters + (lang === 'ru' ? 'м' : 'm') : ''}`).join(', ') + planName: s.planName, + exercises: s.sets.map(set => { + const parts = []; + parts.push(set.exerciseName); + if (set.weight) parts.push(`${set.weight}${lang === 'ru' ? 'кг' : 'kg'}`); + if (set.reps) parts.push(`${set.reps}${lang === 'ru' ? 'повт' : 'reps'}`); + if (set.distanceMeters) parts.push(`${set.distanceMeters}${lang === 'ru' ? 'м' : 'm'}`); + if (set.durationSeconds) parts.push(`${set.durationSeconds}${lang === 'ru' ? 'сек' : 's'}`); + return parts.join(' '); + }).join(', ') })); + // Calculate personal records + const exerciseRecords = new Map(); + history.forEach(session => { + session.sets.forEach(set => { + const current = exerciseRecords.get(set.exerciseName) || {}; + exerciseRecords.set(set.exerciseName, { + maxWeight: Math.max(current.maxWeight || 0, set.weight || 0), + maxReps: Math.max(current.maxReps || 0, set.reps || 0), + maxDistance: Math.max(current.maxDistance || 0, set.distanceMeters || 0) + }); + }); + }); + + const personalRecords = Array.from(exerciseRecords.entries()).map(([name, records]) => ({ + exercise: name, + ...records + })); + + // Build comprehensive system instruction const systemInstruction = lang === 'ru' ? ` - Ты — опытный и поддерживающий фитнес-тренер. + Ты — опытный и поддерживающий фитнес-тренер AI Expert. Твоя задача — анализировать тренировки пользователя и давать краткие, полезные советы на русском языке. - Учитывай вес пользователя (userWeight в json), если он указан, при анализе прогресса в упражнениях с собственным весом. + ДАННЫЕ ПОЛЬЗОВАТЕЛЯ: + ${userProfile ? ` + - Вес: ${userProfile.weight || 'не указан'} кг + - Рост: ${userProfile.height || 'не указан'} см + - Пол: ${userProfile.gender || 'не указан'} + ` : 'Профиль не заполнен'} - Вот последние 10 тренировок пользователя (в формате JSON): - ${JSON.stringify(summary)} - - Если пользователь спрашивает о прогрессе, используй эти данные. - Отвечай емко, мотивирующе. Избегай длинных лекций, если не просили. + ТРЕНИРОВОЧНЫЕ ПЛАНЫ: + ${plans && plans.length > 0 ? JSON.stringify(plans.map(p => ({ name: p.name, exercises: p.steps.map(s => s.exerciseName) }))) : 'Нет активных планов'} + + ИСТОРИЯ ТРЕНИРОВОК (последние 20): + ${JSON.stringify(workoutSummary, null, 2)} + + ЛИЧНЫЕ РЕКОРДЫ: + ${JSON.stringify(personalRecords, null, 2)} + + ИНСТРУКЦИИ: + - Используй эти данные для анализа прогресса и ответов на вопросы + - Учитывай вес пользователя при анализе упражнений с собственным весом + - Будь конкретным и ссылайся на реальные данные из истории + - Отвечай емко, мотивирующе + - Если данных недостаточно для ответа, честно скажи об этом ` : ` - You are an experienced and supportive fitness coach. + You are an experienced and supportive fitness coach called AI Expert. Your task is to analyze the user's workouts and provide concise, helpful advice in English. - Consider the user's weight (userWeight in json), if provided, when analyzing progress in bodyweight exercises. + USER PROFILE: + ${userProfile ? ` + - Weight: ${userProfile.weight || 'not specified'} kg + - Height: ${userProfile.height || 'not specified'} cm + - Gender: ${userProfile.gender || 'not specified'} + ` : 'Profile not filled'} - Here are the user's last 10 workouts (in JSON format): - ${JSON.stringify(summary)} - - If the user asks about progress, use this data. - Answer concisely and motivationally. Avoid long lectures unless asked. - ALWAYS answer in the language the user speaks to you, defaulting to English if unsure. + WORKOUT PLANS: + ${plans && plans.length > 0 ? JSON.stringify(plans.map(p => ({ name: p.name, exercises: p.steps.map(s => s.exerciseName) }))) : 'No active plans'} + + WORKOUT HISTORY (last 20 sessions): + ${JSON.stringify(workoutSummary, null, 2)} + + PERSONAL RECORDS: + ${JSON.stringify(personalRecords, null, 2)} + + INSTRUCTIONS: + - Use this data to analyze progress and answer questions + - Consider user's weight when analyzing bodyweight exercises + - Be specific and reference actual data from the history + - Answer concisely and motivationally + - If there's insufficient data to answer, be honest about it + - ALWAYS answer in the language the user speaks to you `; return { sendMessage: async (userMessage: string) => { const res = await api.post('/ai/chat', { systemInstruction, - userMessage + userMessage, + sessionId }); return { text: res.response,