AI coach fixed: acquired access to context data
This commit is contained in:
10
App.tsx
10
App.tsx
@@ -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}
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user