AI Plan generation enhanced
This commit is contained in:
@@ -181,7 +181,7 @@ const SortablePlanStep: React.FC<SortablePlanStepProps> = ({ step, index, toggle
|
||||
const Plans: React.FC<PlansProps> = ({ lang }) => {
|
||||
const { currentUser } = useAuth();
|
||||
const userId = currentUser?.id || '';
|
||||
const { plans, savePlan, deletePlan, refreshData } = useSession();
|
||||
const { plans, sessions, savePlan, deletePlan, refreshData } = useSession();
|
||||
const { startSession } = useActiveWorkout();
|
||||
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
@@ -226,7 +226,9 @@ const Plans: React.FC<PlansProps> = ({ lang }) => {
|
||||
const [aiLoading, setAILoading] = useState(false);
|
||||
const [aiError, setAIError] = useState<string | null>(null);
|
||||
const [aiDuration, setAIDuration] = useState(60); // Default 1 hour in minutes
|
||||
const [aiEquipment, setAIEquipment] = useState<'none' | 'essentials' | 'free_weights' | 'full_gym'>('full_gym');
|
||||
const [aiEquipment, setAIEquipment] = useState<'none' | 'essentials' | 'free_weights' | 'full_gym'>('none');
|
||||
const [aiLevel, setAILevel] = useState<'beginner' | 'intermediate' | 'advanced'>('intermediate');
|
||||
const [aiIntensity, setAIIntensity] = useState<'low' | 'moderate' | 'high'>('moderate');
|
||||
const [generatedPlanPreview, setGeneratedPlanPreview] = useState<{
|
||||
name: string;
|
||||
description: string;
|
||||
@@ -417,7 +419,17 @@ const Plans: React.FC<PlansProps> = ({ lang }) => {
|
||||
? aiPrompt
|
||||
: (lang === 'ru' ? 'Создай план тренировки' : 'Create a workout plan');
|
||||
|
||||
const aiPlan = await generateWorkoutPlan(prompt, availableNames, lang, aiDuration, aiEquipment);
|
||||
const aiPlan = await generateWorkoutPlan(
|
||||
prompt,
|
||||
availableNames,
|
||||
lang,
|
||||
aiDuration,
|
||||
aiEquipment,
|
||||
aiLevel,
|
||||
aiIntensity,
|
||||
sessions,
|
||||
currentUser?.profile
|
||||
);
|
||||
setGeneratedPlanPreview(aiPlan);
|
||||
} catch (err: any) {
|
||||
console.error('AI plan generation error:', err);
|
||||
@@ -488,7 +500,9 @@ const Plans: React.FC<PlansProps> = ({ lang }) => {
|
||||
// Reset state and close
|
||||
setAIPrompt('');
|
||||
setAIDuration(60);
|
||||
setAIEquipment('full_gym');
|
||||
setAIEquipment('none');
|
||||
setAILevel('intermediate');
|
||||
setAIIntensity('moderate');
|
||||
setGeneratedPlanPreview(null);
|
||||
setShowAISheet(false);
|
||||
setFabMenuOpen(false);
|
||||
@@ -813,6 +827,46 @@ const Plans: React.FC<PlansProps> = ({ lang }) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Level Selector */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-on-surface">{t('level', lang)}</label>
|
||||
<div className="flex gap-2">
|
||||
{(['beginner', 'intermediate', 'advanced'] as const).map((lvl) => (
|
||||
<button
|
||||
key={lvl}
|
||||
onClick={() => setAILevel(lvl)}
|
||||
disabled={aiLoading}
|
||||
className={`flex-1 p-2 rounded-xl text-sm font-medium transition-all ${aiLevel === lvl
|
||||
? 'bg-primary text-on-primary'
|
||||
: 'bg-surface-container-high text-on-surface hover:bg-surface-container-highest'
|
||||
}`}
|
||||
>
|
||||
{t(`level_${lvl}` as any, lang)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Intensity Selector */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-on-surface">{t('intensity', lang)}</label>
|
||||
<div className="flex gap-2">
|
||||
{(['low', 'moderate', 'high'] as const).map((int) => (
|
||||
<button
|
||||
key={int}
|
||||
onClick={() => setAIIntensity(int)}
|
||||
disabled={aiLoading}
|
||||
className={`flex-1 p-2 rounded-xl text-sm font-medium transition-all ${aiIntensity === int
|
||||
? 'bg-primary text-on-primary'
|
||||
: 'bg-surface-container-high text-on-surface hover:bg-surface-container-highest'
|
||||
}`}
|
||||
>
|
||||
{t(`intensity_${int}` as any, lang)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Additional Requirements */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-on-surface">
|
||||
|
||||
@@ -26,61 +26,22 @@ export const createFitnessChat = (
|
||||
// Generate a unique session ID for this chat instance
|
||||
const sessionId = generateId();
|
||||
|
||||
// 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,
|
||||
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)
|
||||
});
|
||||
});
|
||||
});
|
||||
// Reuse the logic via shared helper if possible, or keep as is if we want to include 'plans'.
|
||||
// The helper getUserContextString currently doesn't include 'plans', so we will use it for history/profile and append plans manually.
|
||||
|
||||
const personalRecords = Array.from(exerciseRecords.entries()).map(([name, records]) => ({
|
||||
exercise: name,
|
||||
...records
|
||||
}));
|
||||
const userContext = getUserContextString(history, userProfile, lang);
|
||||
|
||||
// Build comprehensive system instruction
|
||||
const systemInstruction = lang === 'ru' ? `
|
||||
Ты — опытный и поддерживающий фитнес-тренер AI Coach.
|
||||
Твоя задача — анализировать тренировки пользователя и давать краткие, полезные советы на русском языке.
|
||||
|
||||
ДАННЫЕ ПОЛЬЗОВАТЕЛЯ:
|
||||
${userProfile ? `
|
||||
- Вес: ${userProfile.weight || 'не указан'} кг
|
||||
- Рост: ${userProfile.height || 'не указан'} см
|
||||
- Пол: ${userProfile.gender || 'не указан'}
|
||||
` : 'Профиль не заполнен'}
|
||||
${userContext}
|
||||
|
||||
ТРЕНИРОВОЧНЫЕ ПЛАНЫ:
|
||||
${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)}
|
||||
|
||||
ИНСТРУКЦИИ:
|
||||
- Используй эти данные для анализа прогресса и ответов на вопросы
|
||||
- Учитывай вес пользователя при анализе упражнений с собственным весом
|
||||
@@ -91,22 +52,11 @@ export const createFitnessChat = (
|
||||
You are an experienced and supportive fitness coach called AI Coach.
|
||||
Your task is to analyze the user's workouts and provide concise, helpful advice in English.
|
||||
|
||||
USER PROFILE:
|
||||
${userProfile ? `
|
||||
- Weight: ${userProfile.weight || 'not specified'} kg
|
||||
- Height: ${userProfile.height || 'not specified'} cm
|
||||
- Gender: ${userProfile.gender || 'not specified'}
|
||||
` : 'Profile not filled'}
|
||||
${userContext}
|
||||
|
||||
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
|
||||
@@ -153,32 +103,122 @@ interface AIPlanResponse {
|
||||
|
||||
// ... (other code)
|
||||
|
||||
// Helper to generate user context string
|
||||
const getUserContextString = (history: WorkoutSession[], userProfile: UserProfile | undefined, lang: 'en' | 'ru') => {
|
||||
// Summarize workout history (Last 8 sessions)
|
||||
const workoutSummary = history.slice(0, 8).map(s => ({
|
||||
date: new Date(s.startTime).toLocaleDateString(lang === 'ru' ? 'ru-RU' : 'en-US'),
|
||||
planName: s.planName || (lang === 'ru' ? 'Свободная тренировка' : 'Freestyle'),
|
||||
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'}`);
|
||||
return parts.join(' ');
|
||||
}).join(', ')
|
||||
}));
|
||||
|
||||
// Calculate personal records
|
||||
const exerciseRecords = new Map<string, { maxWeight?: number; maxReps?: 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),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
const personalRecords = Array.from(exerciseRecords.entries())
|
||||
.filter(([_, r]) => (r.maxWeight && r.maxWeight > 0) || (r.maxReps && r.maxReps > 10)) // Filter significant records
|
||||
.map(([name, records]) => ({
|
||||
exercise: name,
|
||||
...records
|
||||
}));
|
||||
|
||||
return lang === 'ru' ? `
|
||||
ПРОФИЛЬ ПОЛЬЗОВАТЕЛЯ:
|
||||
${userProfile ? `
|
||||
- Вес: ${userProfile.weight || 'не указан'} кг
|
||||
- Рост: ${userProfile.height || 'не указан'} см
|
||||
- Пол: ${userProfile.gender || 'не указан'}
|
||||
` : 'Профиль не заполнен'}
|
||||
|
||||
ИСТОРИЯ (последние 8 тренировок):
|
||||
${JSON.stringify(workoutSummary, null, 2)}
|
||||
|
||||
ЛИЧНЫЕ РЕКОРДЫ:
|
||||
${JSON.stringify(personalRecords, null, 2)}
|
||||
` : `
|
||||
USER PROFILE:
|
||||
${userProfile ? `
|
||||
- Weight: ${userProfile.weight || 'not specified'} kg
|
||||
- Height: ${userProfile.height || 'not specified'} cm
|
||||
- Gender: ${userProfile.gender || 'not specified'}
|
||||
` : 'Profile not filled'}
|
||||
|
||||
HISTORY (last 8 sessions):
|
||||
${JSON.stringify(workoutSummary, null, 2)}
|
||||
|
||||
PERSONAL RECORDS:
|
||||
${JSON.stringify(personalRecords, null, 2)}
|
||||
`;
|
||||
};
|
||||
|
||||
export const generateWorkoutPlan = async (
|
||||
userPrompt: string,
|
||||
availableExercises: string[],
|
||||
lang: 'en' | 'ru' = 'en',
|
||||
durationMinutes: number = 60,
|
||||
equipment: 'none' | 'essentials' | 'free_weights' | 'full_gym' = 'full_gym'
|
||||
equipment: 'none' | 'essentials' | 'free_weights' | 'full_gym' = 'full_gym',
|
||||
level: 'beginner' | 'intermediate' | 'advanced' = 'intermediate',
|
||||
intensity: 'low' | 'moderate' | 'high' = 'moderate',
|
||||
history: WorkoutSession[] = [],
|
||||
userProfile?: UserProfile
|
||||
): Promise<AIPlanResponse> => {
|
||||
const equipmentDescription = t(`ai_equipment_desc_${equipment}` as any, lang);
|
||||
const levelText = t(`level_${level}` as any, lang);
|
||||
const intensityText = t(`intensity_${intensity}` as any, lang);
|
||||
const userContext = getUserContextString(history, userProfile, lang);
|
||||
|
||||
const durationText = durationMinutes >= 120
|
||||
? (lang === 'ru' ? '2+ часа' : '2+ hours')
|
||||
: `${durationMinutes} ${lang === 'ru' ? 'минут' : 'minutes'}`;
|
||||
|
||||
const systemInstruction = lang === 'ru'
|
||||
? `Ты — генератор планов тренировок. Верни ТОЛЬКО JSON-объект (без дополнительного текста).
|
||||
? `Ты — генератор планов тренировок. Верни ТОЛЬКО JSON-объект.
|
||||
|
||||
ПАРАМЕТРЫ ТРЕНИРОВКИ:
|
||||
${userContext}
|
||||
|
||||
ПАРАМЕТРЫ ПЛАНА:
|
||||
- Длительность: ${durationText}
|
||||
- Уровень: ${levelText}
|
||||
- Интенсивность: ${intensityText}
|
||||
- Оборудование: ${equipmentDescription}
|
||||
|
||||
ВАЖНО: Каждый элемент массива exercises — это ОДИН подход. Если нужно 3 подхода одного упражнения, оно должно встретиться в списке 3 раза подряд.
|
||||
ВАЖНО:
|
||||
1. Используй историю тренировок и рекорды, чтобы подобрать подходящие веса (или указать их как ориентир в описании) и сложность.
|
||||
2. Проанализируй последние тренировки, чтобы не перегружать недавно работавшие мышцы.
|
||||
3. Каждый элемент массива exercises — это ОДИН подход. Если нужно 3 подхода одного упражнения, оно должно встретиться в списке 3 раза подряд.
|
||||
4. Если план "Full Body" — задействуй ВСЕ крупные мышечные группы.
|
||||
|
||||
ПРАВИЛА НАЗВАНИЙ (ОЧЕНЬ ВАЖНО):
|
||||
1. "Weighted" (с отягощением) — это параметр isWeighted: true, а БОЛЬШЕ НИКОГДА НЕ часть названия.
|
||||
- НЕЛЬЗЯ: "Weighted Pull-ups", "Подтягивания (с весом)"
|
||||
- ПРАВИЛЬНО: "Подтягивания" (isWeighted: true)
|
||||
2. СТРОГО БЕЗ ВАРИАНТОВ ИЛИ ОПЦИЙ.
|
||||
- НЕЛЬЗЯ: "Отжимания (или на коленях)", "Ring Dips (or Barbell)"
|
||||
- ПРАВИЛЬНО: "Отжимания на кольцах"
|
||||
3. СТРОГО БЕЗ ИНСТРУКЦИЙ ПО ТЕХНИКЕ И СКОБОК.
|
||||
- НЕЛЬЗЯ: "Подтягивания (до груди)", "Squats (ass to grass)"
|
||||
- ПРАВИЛЬНО: "Подтягивания", "Приседания"
|
||||
4. Названия должны быть КОРОТКИМИ и ЧИСТЫМИ.
|
||||
|
||||
Формат ответа:
|
||||
{
|
||||
"name": "Название плана",
|
||||
"description": "Описание/инструкции по подготовке",
|
||||
"description": "Описание/инструкции по подготовке (укажи, почему этот план подходит пользователю на основе его истории)",
|
||||
"exercises": [
|
||||
{ "name": "Название упражнения", "isWeighted": false, "restTimeSeconds": 60 }
|
||||
]
|
||||
@@ -189,18 +229,38 @@ export const generateWorkoutPlan = async (
|
||||
|
||||
Доступные упражнения: ${JSON.stringify(availableExercises)}
|
||||
`
|
||||
: `You are a workout plan generator. Return ONLY a JSON object (no extra text).
|
||||
: `You are a workout plan generator. Return ONLY a JSON object.
|
||||
|
||||
${userContext}
|
||||
|
||||
WORKOUT PARAMETERS:
|
||||
- Duration: ${durationText}
|
||||
- Level: ${levelText}
|
||||
- Intensity: ${intensityText}
|
||||
- Equipment: ${equipmentDescription}
|
||||
|
||||
IMPORTANT: Each item in the exercises array represents ONE set. If you need 3 sets of an exercise, it must appear in the list 3 times consecutively.
|
||||
IMPORTANT:
|
||||
1. Use workout history and records to adjust difficulty and suggest weights (in description).
|
||||
2. Analyze recent sessions to avoid overworking recently trained muscles.
|
||||
3. Each item in the exercises array represents ONE set. If you need 3 sets of an exercise, it must appear in the list 3 times consecutively.
|
||||
4. "Full Body" means ALL big muscle groups must be involved.
|
||||
|
||||
EXTREMELY IMPORTANT NAMING RULES:
|
||||
1. "Weighted" must be a parameter (isWeighted: true), NEVER part of the name.
|
||||
- BAD: "Weighted Pull-ups", "Pull-ups (Weighted)"
|
||||
- GOOD: "Pull-ups" (isWeighted: true)
|
||||
2. STRICTLY NO VARIANTS OR ALTERNATIVES.
|
||||
- BAD: "Ring Dips (or Barbell Dips)", "Push-ups (or Kneeling)"
|
||||
- GOOD: "Ring Dips"
|
||||
3. STRICTLY NO FORM NOTES OR PARENTHESES with instructions.
|
||||
- BAD: "Ring Rows (Chest to Rings)", "Squats (ass to grass)"
|
||||
- GOOD: "Ring Rows", "Squats"
|
||||
4. Keep names CLEAN and SHORT.
|
||||
|
||||
Response format:
|
||||
{
|
||||
"name": "Plan Name",
|
||||
"description": "Description/preparation instructions",
|
||||
"description": "Description/preparation instructions (explain why this fits based on user history)",
|
||||
"exercises": [
|
||||
{ "name": "Exercise Name", "isWeighted": false, "restTimeSeconds": 60 }
|
||||
]
|
||||
@@ -209,8 +269,7 @@ If an exercise is NOT in the available list, also add:
|
||||
"type": one of STRENGTH, BODYWEIGHT, CARDIO, STATIC, PLYOMETRIC,
|
||||
"unilateral": true or false
|
||||
|
||||
Available exercises: ${JSON.stringify(availableExercises)}
|
||||
`;
|
||||
Available exercises: ${JSON.stringify(availableExercises)}`;
|
||||
|
||||
const res = await api.post<ApiResponse<{ response: string }>>('/ai/chat', {
|
||||
systemInstruction,
|
||||
|
||||
@@ -140,9 +140,17 @@ const translations = {
|
||||
ai_generated_plan: 'Generated Plan',
|
||||
regenerate: 'Regenerate',
|
||||
ai_equipment_desc_none: 'No equipment - bodyweight exercises only',
|
||||
ai_equipment_desc_essentials: 'Street workout essentials - pull-up bar, dip bar, gymnastics rings',
|
||||
ai_equipment_desc_essentials: 'Street workout essentials - pull-up bar, parallel bars, gymnastics rings',
|
||||
ai_equipment_desc_free_weights: 'Free weights - dumbbells, kettlebells, barbells',
|
||||
ai_equipment_desc_full_gym: 'Complete gym - all machines and equipment available',
|
||||
level: 'Level',
|
||||
level_beginner: 'Beginner',
|
||||
level_intermediate: 'Intermediate',
|
||||
level_advanced: 'Advanced',
|
||||
intensity: 'Intensity',
|
||||
intensity_low: 'Low',
|
||||
intensity_moderate: 'Moderate',
|
||||
intensity_high: 'High',
|
||||
|
||||
// Stats
|
||||
progress: 'Progress',
|
||||
@@ -349,6 +357,14 @@ const translations = {
|
||||
ai_equipment_desc_essentials: 'Street workout - турник, брусья, кольца',
|
||||
ai_equipment_desc_free_weights: 'Свободные веса - гантели, гири, штанга',
|
||||
ai_equipment_desc_full_gym: 'Полный зал - все тренажеры и оборудование',
|
||||
level: 'Уровень',
|
||||
level_beginner: 'Новичок',
|
||||
level_intermediate: 'Средний',
|
||||
level_advanced: 'Продвинутый',
|
||||
intensity: 'Интенсивность',
|
||||
intensity_low: 'Низкая',
|
||||
intensity_moderate: 'Средняя',
|
||||
intensity_high: 'Высокая',
|
||||
|
||||
// Stats
|
||||
progress: 'Прогресс',
|
||||
|
||||
Reference in New Issue
Block a user