AI coach fixed: acquired access to context data

This commit is contained in:
aodulov
2025-11-27 07:51:36 +02:00
parent 4f3a889410
commit 1ef85d7a58
4 changed files with 186 additions and 46 deletions

10
App.tsx
View File

@@ -9,7 +9,7 @@ import Plans from './components/Plans';
import Login from './components/Login'; import Login from './components/Login';
import Profile from './components/Profile'; import Profile from './components/Profile';
import { TabView, WorkoutSession, WorkoutSet, WorkoutPlan, User, Language } from './types'; 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 { getCurrentUserProfile, getMe } from './services/auth';
import { getSystemLanguage } from './services/i18n'; import { getSystemLanguage } from './services/i18n';
@@ -19,6 +19,7 @@ function App() {
const [language, setLanguage] = useState<Language>('en'); const [language, setLanguage] = useState<Language>('en');
const [sessions, setSessions] = useState<WorkoutSession[]>([]); const [sessions, setSessions] = useState<WorkoutSession[]>([]);
const [plans, setPlans] = useState<WorkoutPlan[]>([]);
const [activeSession, setActiveSession] = useState<WorkoutSession | null>(null); const [activeSession, setActiveSession] = useState<WorkoutSession | null>(null);
const [activePlan, setActivePlan] = useState<WorkoutPlan | null>(null); const [activePlan, setActivePlan] = useState<WorkoutPlan | null>(null);
@@ -46,9 +47,12 @@ function App() {
if (currentUser) { if (currentUser) {
const s = await getSessions(currentUser.id); const s = await getSessions(currentUser.id);
setSessions(s); setSessions(s);
// Profile fetch is skipped for now as it returns undefined // Load plans
const p = await getPlans(currentUser.id);
setPlans(p);
} else { } else {
setSessions([]); setSessions([]);
setPlans([]);
} }
}; };
loadSessions(); loadSessions();
@@ -197,7 +201,7 @@ function App() {
/> />
)} )}
{currentTab === 'STATS' && <Stats sessions={sessions} lang={language} />} {currentTab === 'STATS' && <Stats sessions={sessions} lang={language} />}
{currentTab === 'AI_COACH' && <AICoach history={sessions} lang={language} />} {currentTab === 'AI_COACH' && <AICoach history={sessions} userProfile={currentUser.profile} plans={plans} lang={language} />}
{currentTab === 'PROFILE' && ( {currentTab === 'PROFILE' && (
<Profile <Profile
user={currentUser} user={currentUser}

View File

@@ -2,12 +2,14 @@
import React, { useState, useRef, useEffect } from 'react'; import React, { useState, useRef, useEffect } from 'react';
import { Send, Bot, User, Loader2, AlertTriangle } from 'lucide-react'; import { Send, Bot, User, Loader2, AlertTriangle } from 'lucide-react';
import { createFitnessChat } from '../services/geminiService'; import { createFitnessChat } from '../services/geminiService';
import { WorkoutSession, Language } from '../types'; import { WorkoutSession, Language, UserProfile, WorkoutPlan } from '../types';
import { Chat, GenerateContentResponse } from '@google/genai'; import { Chat, GenerateContentResponse } from '@google/genai';
import { t } from '../services/i18n'; import { t } from '../services/i18n';
interface AICoachProps { interface AICoachProps {
history: WorkoutSession[]; history: WorkoutSession[];
userProfile?: UserProfile;
plans?: WorkoutPlan[];
lang: Language; lang: Language;
} }
@@ -17,7 +19,7 @@ interface Message {
text: string; text: string;
} }
const AICoach: React.FC<AICoachProps> = ({ history, lang }) => { const AICoach: React.FC<AICoachProps> = ({ history, userProfile, plans, lang }) => {
const [messages, setMessages] = useState<Message[]>([ const [messages, setMessages] = useState<Message[]>([
{ id: 'intro', role: 'model', text: t('ai_intro', lang) } { id: 'intro', role: 'model', text: t('ai_intro', lang) }
]); ]);
@@ -29,7 +31,7 @@ const AICoach: React.FC<AICoachProps> = ({ history, lang }) => {
useEffect(() => { useEffect(() => {
try { try {
const chat = createFitnessChat(history, lang); const chat = createFitnessChat(history, lang, userProfile, plans);
if (chat) { if (chat) {
chatSessionRef.current = chat; chatSessionRef.current = chat;
} else { } else {
@@ -38,7 +40,7 @@ const AICoach: React.FC<AICoachProps> = ({ history, lang }) => {
} catch (err) { } catch (err) {
setError("Failed to initialize AI"); setError("Failed to initialize AI");
} }
}, [history, lang]); }, [history, lang, userProfile, plans]);
const scrollToBottom = () => { const scrollToBottom = () => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
@@ -57,7 +59,7 @@ const AICoach: React.FC<AICoachProps> = ({ history, lang }) => {
setLoading(true); setLoading(true);
try { 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 text = result.text;
const aiMsg: Message = { const aiMsg: Message = {

View File

@@ -1,18 +1,30 @@
import express from 'express'; import express, { Request, Response, NextFunction } from 'express';
import { GoogleGenerativeAI } from '@google/generative-ai'; import { GoogleGenerativeAI } from '@google/generative-ai';
import jwt from 'jsonwebtoken'; import jwt from 'jsonwebtoken';
interface JwtPayload {
userId: string;
role: string;
}
interface AuthRequest extends Request {
user?: JwtPayload;
}
const router = express.Router(); const router = express.Router();
const JWT_SECRET = process.env.JWT_SECRET || 'secret'; const JWT_SECRET = process.env.JWT_SECRET || 'secret';
const API_KEY = process.env.API_KEY; const API_KEY = process.env.API_KEY;
const MODEL_ID = 'gemini-2.0-flash'; 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<string, any>();
const authenticate = (req: AuthRequest, res: Response, next: NextFunction) => {
const token = req.headers.authorization?.split(' ')[1]; const token = req.headers.authorization?.split(' ')[1];
if (!token) return res.status(401).json({ error: 'Unauthorized' }); if (!token) return res.status(401).json({ error: 'Unauthorized' });
try { try {
const decoded = jwt.verify(token, JWT_SECRET) as any; const decoded = jwt.verify(token, JWT_SECRET) as JwtPayload;
req.user = decoded; req.user = decoded;
next(); next();
} catch { } catch {
@@ -22,31 +34,83 @@ const authenticate = (req: any, res: any, next: any) => {
router.use(authenticate); router.use(authenticate);
router.post('/chat', async (req, res) => { router.post('/chat', async (req: AuthRequest, res: Response) => {
try { try {
const { history, message } = req.body; const { systemInstruction, userMessage, sessionId } = req.body;
if (!API_KEY) { if (!API_KEY) {
return res.status(500).json({ error: 'AI service not configured' }); 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 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({ if (!chat || systemInstruction) {
model: MODEL_ID // 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}`; chat = model.startChat({
const result = await model.generateContent(prompt); history: []
});
chatSessions.set(chatKey, chat);
}
// Send message and get response
const result = await chat.sendMessage(userMessage);
const response = result.response.text(); const response = result.response.text();
res.json({ response }); 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) { } catch (error) {
console.error(error); console.error('Clear chat error:', error);
res.status(500).json({ error: String(error) }); res.status(500).json({ error: 'Failed to clear chat session' });
} }
}); });
export default router; export default router;

View File

@@ -1,49 +1,119 @@
import { WorkoutSession } from '../types'; import { WorkoutSession, UserProfile, WorkoutPlan } from '../types';
import { api } from './api'; import { api } from './api';
export const createFitnessChat = (history: WorkoutSession[], lang: 'en' | 'ru' = 'ru'): any => { interface FitnessChatOptions {
// The original returned a Chat object. history: WorkoutSession[];
// Now we need to return something that behaves like it or refactor the UI. userProfile?: UserProfile;
// The UI likely calls `chat.sendMessage(msg)`. plans?: WorkoutPlan[];
// So we return an object with `sendMessage`. lang?: 'en' | 'ru';
sessionId?: string;
}
// Summarize data to reduce token count while keeping relevant context export const createFitnessChat = (
const summary = history.slice(0, 10).map(s => ({ 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'), date: new Date(s.startTime).toLocaleDateString(lang === 'ru' ? 'ru-RU' : 'en-US'),
userWeight: s.userBodyWeight, 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<string, { maxWeight?: number; maxReps?: number; maxDistance?: number }>();
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' ? ` 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. 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): WORKOUT PLANS:
${JSON.stringify(summary)} ${plans && plans.length > 0 ? JSON.stringify(plans.map(p => ({ name: p.name, exercises: p.steps.map(s => s.exerciseName) }))) : 'No active plans'}
If the user asks about progress, use this data. WORKOUT HISTORY (last 20 sessions):
Answer concisely and motivationally. Avoid long lectures unless asked. ${JSON.stringify(workoutSummary, null, 2)}
ALWAYS answer in the language the user speaks to you, defaulting to English if unsure.
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 { return {
sendMessage: async (userMessage: string) => { sendMessage: async (userMessage: string) => {
const res = await api.post('/ai/chat', { const res = await api.post('/ai/chat', {
systemInstruction, systemInstruction,
userMessage userMessage,
sessionId
}); });
return { return {
text: res.response, text: res.response,