Add mobile app support and opening training feature
This commit implements iOS/Android mobile app support using Capacitor and adds a comprehensive opening training feature with LLM-powered explanations. ## Mobile App Infrastructure - Add Capacitor configuration for iOS/Android builds - Create mobile build script that excludes API routes - Update Next.js config for conditional static export - Add layout components with generateStaticParams for static builds - Generate 500+ static pages for offline mobile use ## Chess Engine Abstraction - Create ChessEngine interface for pluggable implementations - Add LocalEngine (GPL - uses stockfish.js in browser) - Add RemoteEngine (proprietary - calls API server) - Factory pattern selects engine based on environment - Enables GPL compliance for web, proprietary for mobile ## Opening Training Feature - Interactive opening repertoire training - Move validation with engine-backed feedback - LLM explanations using Gemini API - Wikipedia integration for opening context - Opening family grouping (e4, d4, c4, etc.) - Session state management - Real-time move feedback with evaluation Components: - OpeningSelector: Browse and select openings by family - OpeningTrainer: Main training interface with chessboard - MoveFeedback: Display move quality and LLM explanations - WikipediaSummary: Show opening history and context - ErrorBoundary: Graceful error handling Services: - openingLoader: Load and filter opening database - engineService: Engine evaluation wrapper - moveValidator: Validate moves against repertoire - feedbackGenerator: Generate contextual feedback - wikipediaService: Fetch and cache Wikipedia data - sessionManager: Track training session state ## Wikipedia Integration - Automatic Wikipedia article fetching for openings - Client-side and server-side caching - Sanitized summaries with proper formatting - Link opening database to Wikipedia slugs - API endpoints for on-demand fetching ## Docker Improvements - Add entrypoint script for automatic data setup - Fetch Wikipedia data on first container startup - Generate opening move index automatically - Remove generated data from git (public/openings/*.json, public/wikipedia/*.json) - Add READMEs explaining data requirements - Update .gitignore for generated files ## Dual Licensing Strategy - Add LICENSING.md explaining dual licensing approach - GPL-3.0 for web builds (includes Stockfish) - Proprietary option for mobile builds (no GPL code) - Single codebase, multiple licensing models - Legal compliance documented ## API Endpoints - POST /api/v1/llm/opening-explanation - Get LLM move explanations - GET /api/v1/wikipedia/summary - Fetch Wikipedia summaries ## Type Updates - Add openingTraining types - Update Tutor component to use ChessEngine interface - Add Gemini error handling types 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,150 @@
|
||||
'use client';
|
||||
|
||||
import { AlertCircle, ExternalLink, X, Settings, Key } from 'lucide-react';
|
||||
import { GeminiErrorInfo } from '@/lib/geminiErrorHandler';
|
||||
import { ApiKeyInfo, getApiKeySourceDescription } from '@/lib/apiKeyHelper';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
interface GeminiErrorModalProps {
|
||||
error: GeminiErrorInfo;
|
||||
apiKeyInfo: ApiKeyInfo;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function GeminiErrorModal({ error, apiKeyInfo, onClose }: GeminiErrorModalProps) {
|
||||
const router = useRouter();
|
||||
|
||||
const handleGoToSettings = () => {
|
||||
onClose();
|
||||
router.push('/settings');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-md w-full">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-6 border-b border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-red-100 dark:bg-red-900/30 rounded-full flex items-center justify-center">
|
||||
<AlertCircle className="text-red-600 dark:text-red-400" size={24} />
|
||||
</div>
|
||||
<h2 className="text-xl font-bold text-gray-900 dark:text-white">
|
||||
{error.isQuotaError ? 'API Quota Exceeded' : 'API Error'}
|
||||
</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
|
||||
aria-label="Close"
|
||||
>
|
||||
<X size={24} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-6 space-y-4">
|
||||
{/* API Key Info */}
|
||||
<div className="bg-gray-50 dark:bg-gray-900/50 border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<Key className="text-gray-600 dark:text-gray-400 flex-shrink-0 mt-0.5" size={18} />
|
||||
<div className="flex-1 space-y-1">
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
Current API Key:
|
||||
</p>
|
||||
<p className="text-sm font-mono text-gray-700 dark:text-gray-300">
|
||||
{apiKeyInfo.anonymized}
|
||||
</p>
|
||||
<p className="text-xs text-gray-600 dark:text-gray-400">
|
||||
Source: {getApiKeySourceDescription(apiKeyInfo.source)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-gray-700 dark:text-gray-300">
|
||||
{error.userMessage}
|
||||
</p>
|
||||
|
||||
{error.retryAfterSeconds && (
|
||||
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
|
||||
<p className="text-sm text-blue-800 dark:text-blue-300">
|
||||
You can try again in <strong>{error.retryAfterSeconds} seconds</strong>.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error.isQuotaError && (
|
||||
<div className="space-y-3">
|
||||
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-4">
|
||||
<p className="text-sm text-yellow-800 dark:text-yellow-300 mb-2">
|
||||
<strong>Solutions:</strong>
|
||||
</p>
|
||||
<ul className="text-sm text-yellow-800 dark:text-yellow-300 list-disc list-inside space-y-1">
|
||||
{apiKeyInfo.source === 'localStorage' && (
|
||||
<li>Change your API key to a different one with available quota</li>
|
||||
)}
|
||||
<li>Upgrade to a paid Gemini API plan for higher quotas</li>
|
||||
<li>Wait until tomorrow for your free tier quota to reset</li>
|
||||
<li>Use the chess tutor less frequently throughout the day</li>
|
||||
{apiKeyInfo.source === 'env' && (
|
||||
<li>Update the NEXT_PUBLIC_GEMINI_API_KEY environment variable</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Change API Key button (if from localStorage) */}
|
||||
{apiKeyInfo.source === 'localStorage' && (
|
||||
<button
|
||||
onClick={handleGoToSettings}
|
||||
className="flex items-center justify-center gap-2 w-full px-4 py-3 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors font-medium"
|
||||
>
|
||||
<Settings size={16} />
|
||||
Change API Key in Settings
|
||||
</button>
|
||||
)}
|
||||
|
||||
<a
|
||||
href="https://ai.google.dev/pricing"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center justify-center gap-2 w-full px-4 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors font-medium"
|
||||
>
|
||||
View Gemini API Pricing
|
||||
<ExternalLink size={16} />
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="https://ai.dev/usage"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center justify-center gap-2 w-full px-4 py-3 bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors font-medium"
|
||||
>
|
||||
Check Your API Usage
|
||||
<ExternalLink size={16} />
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Technical details (collapsible) */}
|
||||
<details className="mt-4">
|
||||
<summary className="text-sm text-gray-500 dark:text-gray-400 cursor-pointer hover:text-gray-700 dark:hover:text-gray-300">
|
||||
Technical details
|
||||
</summary>
|
||||
<pre className="mt-2 p-3 bg-gray-100 dark:bg-gray-900 rounded text-xs text-gray-600 dark:text-gray-400 overflow-x-auto">
|
||||
{error.technicalMessage}
|
||||
</pre>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="p-6 border-t border-gray-200 dark:border-gray-700 flex justify-end">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-6 py-2 bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors font-medium"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
'use client';
|
||||
|
||||
import React, { Component, ErrorInfo, ReactNode } from 'react';
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
fallback?: ReactNode;
|
||||
}
|
||||
|
||||
interface State {
|
||||
hasError: boolean;
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Error Boundary for Opening Trainer components
|
||||
* Catches and handles runtime errors gracefully
|
||||
*/
|
||||
export class OpeningTrainerErrorBoundary extends Component<Props, State> {
|
||||
public state: State = {
|
||||
hasError: false,
|
||||
error: null,
|
||||
};
|
||||
|
||||
public static getDerivedStateFromError(error: Error): State {
|
||||
return { hasError: true, error };
|
||||
}
|
||||
|
||||
public componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
||||
console.error('Opening Trainer Error:', error, errorInfo);
|
||||
}
|
||||
|
||||
private handleReset = () => {
|
||||
this.setState({ hasError: false, error: null });
|
||||
// Reload the page to restart the training session
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
public render() {
|
||||
if (this.state.hasError) {
|
||||
if (this.props.fallback) {
|
||||
return this.props.fallback;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-[400px] flex items-center justify-center p-8">
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-6 max-w-2xl">
|
||||
<h2 className="text-xl font-bold text-red-900 mb-4">
|
||||
Something went wrong
|
||||
</h2>
|
||||
<p className="text-red-700 mb-4">
|
||||
{this.state.error?.message ||
|
||||
'An unexpected error occurred in the opening trainer.'}
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
<button
|
||||
onClick={this.handleReset}
|
||||
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 font-medium"
|
||||
>
|
||||
Restart Training Session
|
||||
</button>
|
||||
<button
|
||||
onClick={() => (window.location.href = '/learning/openings')}
|
||||
className="ml-2 px-4 py-2 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300 font-medium"
|
||||
>
|
||||
Back to Opening Selection
|
||||
</button>
|
||||
</div>
|
||||
{process.env.NODE_ENV === 'development' && this.state.error && (
|
||||
<details className="mt-4 text-sm text-gray-600">
|
||||
<summary className="cursor-pointer font-medium">
|
||||
Error Details (Development Only)
|
||||
</summary>
|
||||
<pre className="mt-2 p-2 bg-gray-100 rounded overflow-auto">
|
||||
{this.state.error.stack}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import { OpeningFamily } from '@/lib/openingTrainer/openingFamilies';
|
||||
|
||||
interface FamilySelectorProps {
|
||||
families: OpeningFamily[];
|
||||
onSelectFamily: (familyName: string) => void;
|
||||
}
|
||||
|
||||
export default function FamilySelector({ families, onSelectFamily }: FamilySelectorProps) {
|
||||
// Group families by ECO range for display
|
||||
const groupedFamilies = useMemo(() => {
|
||||
const groups: Record<string, OpeningFamily[]> = {
|
||||
'White Openings (1.e4)': [],
|
||||
'White Openings (1.d4)': [],
|
||||
'Black Defenses vs 1.e4': [],
|
||||
'Black Defenses vs 1.d4': [],
|
||||
'Other Openings': [],
|
||||
};
|
||||
|
||||
families.forEach(family => {
|
||||
const firstEco = family.ecoRange[0];
|
||||
|
||||
if (firstEco === 'C') {
|
||||
groups['White Openings (1.e4)'].push(family);
|
||||
} else if (firstEco === 'D') {
|
||||
groups['White Openings (1.d4)'].push(family);
|
||||
} else if (firstEco === 'B') {
|
||||
groups['Black Defenses vs 1.e4'].push(family);
|
||||
} else if (firstEco === 'E') {
|
||||
groups['Black Defenses vs 1.d4'].push(family);
|
||||
} else {
|
||||
groups['Other Openings'].push(family);
|
||||
}
|
||||
});
|
||||
|
||||
return groups;
|
||||
}, [families]);
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{Object.entries(groupedFamilies).map(([category, categoryFamilies]) => {
|
||||
if (categoryFamilies.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div key={category} className="space-y-4">
|
||||
<h2 className="text-xl font-bold text-gray-900 dark:text-white">
|
||||
{category} ({categoryFamilies.length})
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{categoryFamilies.map((family) => (
|
||||
<button
|
||||
key={family.name}
|
||||
onClick={() => onSelectFamily(family.name)}
|
||||
className="block p-6 border-2 border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 rounded-lg hover:border-blue-500 dark:hover:border-blue-400 hover:shadow-lg transition-all text-left"
|
||||
aria-label={`Select ${family.name} opening family`}
|
||||
>
|
||||
<h3 className="font-bold text-lg text-gray-900 dark:text-white mb-3">
|
||||
{family.name}
|
||||
</h3>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-gray-600 dark:text-gray-400">Variations:</span>
|
||||
<span className="font-semibold text-blue-600 dark:text-blue-400">
|
||||
{family.variationCount}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-gray-600 dark:text-gray-400">ECO Range:</span>
|
||||
<span className="font-mono text-xs text-gray-700 dark:text-gray-300">
|
||||
{family.ecoRange}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-gray-600 dark:text-gray-400">Total Moves:</span>
|
||||
<span className="font-semibold text-gray-700 dark:text-gray-300">
|
||||
{family.totalMoves}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Popularity indicator */}
|
||||
{family.popularity >= 2.5 && (
|
||||
<div className="mt-3 pt-3 border-t border-gray-200 dark:border-gray-700">
|
||||
<span className="inline-flex items-center text-xs font-medium text-green-700 dark:text-green-400 bg-green-100 dark:bg-green-900/30 px-2 py-1 rounded">
|
||||
⭐ Popular
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
'use client';
|
||||
|
||||
import { MoveFeedback as MoveFeedbackType } from '@/types/openingTraining';
|
||||
import { formatEvaluation } from '@/lib/openingTrainer/moveValidator';
|
||||
|
||||
interface MoveFeedbackProps {
|
||||
feedback: MoveFeedbackType;
|
||||
}
|
||||
|
||||
export default function MoveFeedback({ feedback }: MoveFeedbackProps) {
|
||||
const { move, classification, evaluation, previousEvaluation, llmExplanation } =
|
||||
feedback;
|
||||
|
||||
// Format evaluation change
|
||||
const evalChange = classification.evaluationChange !== 0
|
||||
? `${classification.evaluationChange > 0 ? '+' : ''}${(classification.evaluationChange / 100).toFixed(2)}`
|
||||
: '0.00';
|
||||
|
||||
// Category badge styling
|
||||
const categoryStyles = {
|
||||
'in-theory': 'bg-green-100 text-green-800 border-green-300',
|
||||
playable: 'bg-yellow-100 text-yellow-800 border-yellow-300',
|
||||
weak: 'bg-red-100 text-red-800 border-red-300',
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow-lg p-4 space-y-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="font-semibold text-gray-900">
|
||||
Move {move.moveNumber}
|
||||
{move.color === 'white' ? '.' : '...'} {move.san}
|
||||
</h3>
|
||||
<div
|
||||
className={`px-3 py-1 rounded border text-sm font-medium ${
|
||||
categoryStyles[classification.category]
|
||||
}`}
|
||||
>
|
||||
{classification.category.toUpperCase()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Classification details */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<span
|
||||
className={`inline-block w-2 h-2 rounded-full ${
|
||||
classification.inRepertoire ? 'bg-green-500' : 'bg-gray-400'
|
||||
}`}
|
||||
></span>
|
||||
<span className="text-gray-700">
|
||||
{classification.inRepertoire
|
||||
? 'In repertoire'
|
||||
: 'Outside repertoire'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{classification.theoreticalAlternatives &&
|
||||
classification.theoreticalAlternatives.length > 0 && (
|
||||
<div className="text-sm">
|
||||
<span className="text-gray-600">Repertoire alternatives: </span>
|
||||
<span className="font-mono text-gray-900">
|
||||
{classification.theoreticalAlternatives.join(', ')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Engine evaluation */}
|
||||
<div className="bg-gray-50 rounded-lg p-3 space-y-2">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-gray-600 font-medium">Evaluation:</span>
|
||||
<span className="font-mono text-gray-900 font-semibold">
|
||||
{formatEvaluation(evaluation)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{evaluation.bestMove && (
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-gray-600 font-medium">Engine best:</span>
|
||||
<span className="font-mono text-gray-900">
|
||||
{evaluation.bestMove}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{evalChange && (
|
||||
<div
|
||||
className={`flex items-center justify-between text-sm ${
|
||||
classification.isSignificantSwing ? 'font-bold' : ''
|
||||
}`}
|
||||
>
|
||||
<span className="text-gray-600 font-medium">
|
||||
Eval change:
|
||||
{classification.isSignificantSwing && (
|
||||
<span className="ml-1 text-xs bg-orange-100 text-orange-800 px-1 rounded">
|
||||
Significant
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span
|
||||
className={`font-mono ${
|
||||
evalChange.startsWith('+')
|
||||
? 'text-green-700'
|
||||
: 'text-red-700'
|
||||
}`}
|
||||
>
|
||||
{evalChange}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* LLM Explanation */}
|
||||
{llmExplanation && (
|
||||
<div className="pt-3 border-t border-gray-200">
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="flex-shrink-0 w-6 h-6 bg-blue-100 rounded-full flex items-center justify-center">
|
||||
<span className="text-blue-600 text-xs font-bold">AI</span>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="text-sm text-gray-700 leading-relaxed">
|
||||
{llmExplanation}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Loading state for explanation */}
|
||||
{!llmExplanation && classification.category !== 'in-theory' && (
|
||||
<div className="pt-3 border-t border-gray-200">
|
||||
<div className="flex items-center gap-2 text-sm text-gray-500">
|
||||
<div className="w-4 h-4 border-2 border-blue-600 border-t-transparent rounded-full animate-spin"></div>
|
||||
<span>Generating explanation...</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useMemo } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { OpeningMetadata } from '@/lib/openings';
|
||||
import { loadSession } from '@/lib/openingTrainer/sessionManager';
|
||||
|
||||
interface OpeningSelectorProps {
|
||||
openings: OpeningMetadata[];
|
||||
selectedFamily?: string;
|
||||
onBackToFamilies?: () => void;
|
||||
}
|
||||
|
||||
// Helper function to count moves in an opening
|
||||
const countMoves = (movesString: string): number => {
|
||||
if (!movesString) return 0;
|
||||
// Filter out move numbers (e.g., "1.", "2.") and count actual moves
|
||||
const moves = movesString.split(' ').filter(m => !m.match(/^\d+\.$/));
|
||||
return moves.length;
|
||||
};
|
||||
|
||||
// Helper function to extract variation name (after family prefix)
|
||||
const getVariationName = (fullName: string, familyName?: string): string => {
|
||||
if (!familyName) return fullName;
|
||||
|
||||
// Remove family prefix and common delimiters
|
||||
const separators = [':', ',', '–', '—', ' - '];
|
||||
for (const sep of separators) {
|
||||
if (fullName.includes(sep)) {
|
||||
const parts = fullName.split(sep);
|
||||
if (parts.length > 1) {
|
||||
return parts.slice(1).join(sep).trim();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If no separator found, return full name
|
||||
return fullName;
|
||||
};
|
||||
|
||||
// Helper function to determine opening popularity for sorting
|
||||
const getPopularityScore = (eco: string): number => {
|
||||
// Very popular openings (most common in practice)
|
||||
const veryPopular = ['C50', 'C55', 'C60', 'C65', 'C80', 'C90', 'D00', 'D06', 'D30', 'D35', 'D37', 'E00', 'E20', 'E60', 'E90', 'B10', 'B12', 'B20', 'B30', 'B33', 'B40', 'B50', 'B90'];
|
||||
if (veryPopular.some(code => eco.startsWith(code))) return 3;
|
||||
|
||||
// Popular openings
|
||||
const popular = ['A00', 'A04', 'A10', 'A40', 'A45', 'C00', 'C01', 'C02', 'C10', 'C15', 'C20', 'C30', 'C40', 'D10', 'D20', 'D40', 'D50', 'D60', 'D70', 'D80', 'E10', 'E30', 'E40', 'E50', 'E70', 'B00', 'B01', 'B02'];
|
||||
if (popular.some(code => eco.startsWith(code))) return 2;
|
||||
|
||||
// Less common
|
||||
return 1;
|
||||
};
|
||||
|
||||
export default function OpeningSelector({ openings, selectedFamily, onBackToFamilies }: OpeningSelectorProps) {
|
||||
const [colorFilter, setColorFilter] = useState<'all' | 'white' | 'black'>('all');
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
|
||||
// Helper to extract family name from opening name
|
||||
const extractFamilyName = (openingName: string): string => {
|
||||
const separators = [':', ',', '–', '—', ' - '];
|
||||
for (const sep of separators) {
|
||||
if (openingName.includes(sep)) {
|
||||
return openingName.split(sep)[0].trim();
|
||||
}
|
||||
}
|
||||
return openingName;
|
||||
};
|
||||
|
||||
// Filter openings based on color, search query, family, and move count
|
||||
const filteredOpenings = useMemo(() => {
|
||||
return openings.filter((opening) => {
|
||||
// Filter out openings with only 1 move (not useful for training)
|
||||
const moveCount = countMoves(opening.moves);
|
||||
if (moveCount <= 1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Family filter (if a family is selected)
|
||||
if (selectedFamily) {
|
||||
const family = extractFamilyName(opening.name);
|
||||
if (family !== selectedFamily) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Color filter (based on ECO code patterns)
|
||||
// A00-A99, B00-B99, C00-C99 are generally White openings
|
||||
// D00-D99, E00-E99 are generally Black defenses
|
||||
if (colorFilter !== 'all') {
|
||||
const ecoLetter = opening.eco[0];
|
||||
if (colorFilter === 'white' && !['A', 'B', 'C'].includes(ecoLetter)) {
|
||||
return false;
|
||||
}
|
||||
if (colorFilter === 'black' && !['D', 'E'].includes(ecoLetter)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Search filter
|
||||
if (searchQuery) {
|
||||
const query = searchQuery.toLowerCase();
|
||||
return (
|
||||
opening.name.toLowerCase().includes(query) ||
|
||||
opening.eco.toLowerCase().includes(query)
|
||||
);
|
||||
}
|
||||
|
||||
return true;
|
||||
}).sort((a, b) => {
|
||||
// Sort by move count (descending) when family is selected, otherwise by popularity
|
||||
if (selectedFamily) {
|
||||
const movesA = countMoves(a.moves);
|
||||
const movesB = countMoves(b.moves);
|
||||
if (movesA !== movesB) {
|
||||
return movesB - movesA; // More moves first
|
||||
}
|
||||
} else {
|
||||
const scoreA = getPopularityScore(a.eco);
|
||||
const scoreB = getPopularityScore(b.eco);
|
||||
if (scoreA !== scoreB) {
|
||||
return scoreB - scoreA; // Higher score first
|
||||
}
|
||||
}
|
||||
return a.eco.localeCompare(b.eco); // Alphabetical by ECO
|
||||
});
|
||||
}, [openings, colorFilter, searchQuery, selectedFamily]);
|
||||
|
||||
// Group openings by ECO family (first letter)
|
||||
const groupedOpenings = useMemo(() => {
|
||||
const groups: Record<string, OpeningMetadata[]> = {
|
||||
A: [],
|
||||
B: [],
|
||||
C: [],
|
||||
D: [],
|
||||
E: [],
|
||||
};
|
||||
|
||||
filteredOpenings.forEach((opening) => {
|
||||
const family = opening.eco[0];
|
||||
if (groups[family]) {
|
||||
groups[family].push(opening);
|
||||
}
|
||||
});
|
||||
|
||||
return groups;
|
||||
}, [filteredOpenings]);
|
||||
|
||||
// Check if an opening has an active session
|
||||
const hasActiveSession = (eco: string): boolean => {
|
||||
return loadSession(eco) !== null;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Back button and header (when family is selected) */}
|
||||
{selectedFamily && onBackToFamilies && (
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={onBackToFamilies}
|
||||
className="px-4 py-2 bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors font-medium"
|
||||
>
|
||||
← Back to Families
|
||||
</button>
|
||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
{selectedFamily} - Select Variation
|
||||
</h2>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex flex-col sm:flex-row gap-4">
|
||||
{/* Color filter */}
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setColorFilter('all')}
|
||||
className={`px-4 py-2 rounded-lg transition-colors ${
|
||||
colorFilter === 'all'
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-300 dark:hover:bg-gray-600'
|
||||
}`}
|
||||
>
|
||||
All Openings
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setColorFilter('white')}
|
||||
className={`px-4 py-2 rounded-lg transition-colors ${
|
||||
colorFilter === 'white'
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-300 dark:hover:bg-gray-600'
|
||||
}`}
|
||||
>
|
||||
White
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setColorFilter('black')}
|
||||
className={`px-4 py-2 rounded-lg transition-colors ${
|
||||
colorFilter === 'black'
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-300 dark:hover:bg-gray-600'
|
||||
}`}
|
||||
>
|
||||
Black
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search openings..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="flex-1 px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder-gray-500 dark:placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Results count */}
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Showing {filteredOpenings.length} opening{filteredOpenings.length !== 1 ? 's' : ''}
|
||||
</p>
|
||||
|
||||
{/* Grouped openings */}
|
||||
{Object.entries(groupedOpenings).map(([family, familyOpenings]) => {
|
||||
if (familyOpenings.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div key={family} className="space-y-3">
|
||||
<h3 className="text-lg font-semibold text-gray-800 dark:text-gray-200">
|
||||
ECO {family} ({familyOpenings.length})
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{familyOpenings.map((opening) => {
|
||||
const hasSession = hasActiveSession(opening.eco);
|
||||
const moveCount = countMoves(opening.moves);
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={opening.eco}
|
||||
href={`/learning/openings/${opening.eco}`}
|
||||
data-opening-eco={opening.eco}
|
||||
data-testid={`opening-card-${opening.eco}`}
|
||||
className="block p-4 border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 rounded-lg hover:border-blue-500 dark:hover:border-blue-400 hover:shadow-md transition-all"
|
||||
aria-label={`Select ${opening.name} opening`}
|
||||
>
|
||||
<div className="flex justify-between items-start mb-2">
|
||||
<h4 className="font-semibold text-gray-900 dark:text-white">
|
||||
{selectedFamily ? getVariationName(opening.name, selectedFamily) : opening.name}
|
||||
</h4>
|
||||
<div className="flex gap-2">
|
||||
{hasSession && (
|
||||
<span className="text-xs bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-400 px-2 py-1 rounded">
|
||||
In Progress
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">{opening.eco}</p>
|
||||
<span className="text-xs bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-400 px-2 py-1 rounded font-medium">
|
||||
{moveCount} move{moveCount !== 1 ? 's' : ''}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-500 font-mono truncate">
|
||||
{opening.moves.substring(0, 30)}
|
||||
{opening.moves.length > 30 ? '...' : ''}
|
||||
</p>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{filteredOpenings.length === 0 && (
|
||||
<div className="text-center py-12 text-gray-500 dark:text-gray-400">
|
||||
No openings found matching your filters.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,471 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Chess } from 'chess.js';
|
||||
import { Chessboard } from 'react-chessboard';
|
||||
import { OpeningMetadata } from '@/lib/openings';
|
||||
import { useOpeningTraining } from '@/contexts/OpeningTrainingContext';
|
||||
import { loadSession } from '@/lib/openingTrainer/sessionManager';
|
||||
import { parseMoveSequence, getUserColor } from '@/lib/openingTrainer/repertoireNavigation';
|
||||
import { getWikipediaSummary } from '@/lib/openingTrainer/wikipediaService';
|
||||
import { WikipediaSummary as WikipediaSummaryType } from '@/types/openingTraining';
|
||||
import { extractFamilyName } from '@/lib/openingTrainer/openingFamilies';
|
||||
import WikipediaSummary from './WikipediaSummary';
|
||||
import { Tutor } from '@/components/Tutor';
|
||||
import { Personality } from '@/lib/personalities';
|
||||
import { SupportedLanguage } from '@/lib/i18n/translations';
|
||||
|
||||
interface OpeningTrainerProps {
|
||||
opening: OpeningMetadata;
|
||||
personality: Personality;
|
||||
apiKey: string;
|
||||
language: SupportedLanguage;
|
||||
}
|
||||
|
||||
export default function OpeningTrainer({ opening, personality, apiKey, language }: OpeningTrainerProps) {
|
||||
const {
|
||||
session,
|
||||
chess,
|
||||
stockfish,
|
||||
currentFeedback,
|
||||
initializeSession,
|
||||
makeMove,
|
||||
navigateToMove,
|
||||
} = useOpeningTraining();
|
||||
|
||||
const [boardOrientation, setBoardOrientation] = useState<'white' | 'black'>(
|
||||
'white'
|
||||
);
|
||||
const [isInitializing, setIsInitializing] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showRecoveryDialog, setShowRecoveryDialog] = useState(false);
|
||||
const [existingSession, setExistingSession] = useState<any>(null);
|
||||
const [wikipediaSummary, setWikipediaSummary] = useState<WikipediaSummaryType | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
checkForExistingSession();
|
||||
}, [opening.eco]);
|
||||
|
||||
// Fetch Wikipedia summary for the opening (using family name for better results)
|
||||
useEffect(() => {
|
||||
const fetchWikipediaSummary = async () => {
|
||||
try {
|
||||
// Extract family name (e.g., "French Defense" from "French Defense: Exchange Variation")
|
||||
// This ensures we find the Wikipedia article for the main opening, not specific variations
|
||||
const familyName = extractFamilyName(opening.name);
|
||||
console.log(`[Wikipedia] Looking up: "${familyName}" (from "${opening.name}")`);
|
||||
|
||||
const summary = await getWikipediaSummary(familyName);
|
||||
setWikipediaSummary(summary);
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch Wikipedia summary:', err);
|
||||
// Silently fail - Wikipedia is nice-to-have, not critical
|
||||
}
|
||||
};
|
||||
|
||||
fetchWikipediaSummary();
|
||||
}, [opening.name]);
|
||||
|
||||
const checkForExistingSession = () => {
|
||||
const saved = loadSession(opening.eco);
|
||||
|
||||
if (saved && saved.moveHistory.length > 0) {
|
||||
// Found existing session with moves
|
||||
setExistingSession(saved);
|
||||
setShowRecoveryDialog(true);
|
||||
setIsInitializing(false);
|
||||
} else {
|
||||
// No existing session or empty session - start fresh
|
||||
initSession(false);
|
||||
}
|
||||
};
|
||||
|
||||
const initSession = async (forceNew: boolean = false) => {
|
||||
setIsInitializing(true);
|
||||
setError(null);
|
||||
setShowRecoveryDialog(false);
|
||||
|
||||
try {
|
||||
await initializeSession(opening, forceNew);
|
||||
|
||||
// Determine board orientation from opening
|
||||
// ECO D and E are typically Black defenses
|
||||
const orientation = ['D', 'E'].includes(opening.eco[0]) ? 'black' : 'white';
|
||||
setBoardOrientation(orientation);
|
||||
} catch (err) {
|
||||
console.error('Session initialization error:', err);
|
||||
setError('Failed to initialize training session');
|
||||
} finally {
|
||||
setIsInitializing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleResumeSession = () => {
|
||||
initSession(false);
|
||||
};
|
||||
|
||||
const handleStartFresh = () => {
|
||||
initSession(true);
|
||||
};
|
||||
|
||||
const handlePieceDrop = (
|
||||
sourceSquare: string,
|
||||
targetSquare: string
|
||||
): boolean => {
|
||||
if (!chess) return false;
|
||||
|
||||
try {
|
||||
// Create a temporary clone to test if the move is legal
|
||||
// WITHOUT modifying the actual chess instance
|
||||
const testChess = new Chess();
|
||||
testChess.loadPgn(chess.pgn());
|
||||
|
||||
// Try to make the move on the clone
|
||||
const move = testChess.move({
|
||||
from: sourceSquare,
|
||||
to: targetSquare,
|
||||
promotion: 'q', // Always promote to queen for simplicity
|
||||
});
|
||||
|
||||
if (move === null) {
|
||||
// Illegal move
|
||||
return false;
|
||||
}
|
||||
|
||||
// Move was legal - process it on the actual chess instance via makeMove
|
||||
makeMove(move.san);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Move error:', error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// Session recovery dialog
|
||||
if (showRecoveryDialog && existingSession) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-center min-h-[500px]">
|
||||
<div className="bg-white rounded-lg shadow-xl p-8 max-w-md">
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-4">
|
||||
Resume Training?
|
||||
</h2>
|
||||
<p className="text-gray-600 mb-6">
|
||||
You have an existing training session for this opening with{' '}
|
||||
<span className="font-semibold">
|
||||
{existingSession.moveHistory.length} move
|
||||
{existingSession.moveHistory.length !== 1 ? 's' : ''}
|
||||
</span>
|
||||
. Would you like to resume where you left off or start fresh?
|
||||
</p>
|
||||
|
||||
<div className="space-y-3">
|
||||
<button
|
||||
onClick={handleResumeSession}
|
||||
className="w-full px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-medium"
|
||||
>
|
||||
Resume Session
|
||||
</button>
|
||||
<button
|
||||
onClick={handleStartFresh}
|
||||
className="w-full px-6 py-3 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300 font-medium"
|
||||
>
|
||||
Start Fresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-gray-500 mt-4 text-center">
|
||||
Last updated:{' '}
|
||||
{new Date(existingSession.lastUpdated).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isInitializing) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-center min-h-[500px]">
|
||||
<div className="text-center space-y-4">
|
||||
<div className="w-12 h-12 border-4 border-blue-600 border-t-transparent rounded-full animate-spin mx-auto"></div>
|
||||
<p className="text-gray-600 dark:text-gray-400">Initializing training session...</p>
|
||||
{!stockfish && (
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">Loading chess engine...</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-6 text-center">
|
||||
<h3 className="font-semibold text-red-900 mb-2">Error</h3>
|
||||
<p className="text-red-700">{error}</p>
|
||||
<button
|
||||
onClick={() => initSession()}
|
||||
className="mt-4 px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!session || !chess) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-6 text-center">
|
||||
<p className="text-gray-600 dark:text-gray-400">No active session</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const currentPosition = chess.fen();
|
||||
const moveCount = session.moveHistory.length;
|
||||
|
||||
// Determine user's color based on opening ECO code
|
||||
const userColor = getUserColor(opening);
|
||||
|
||||
// Build opening practice mode prop for Tutor
|
||||
const repertoireMoves = parseMoveSequence(opening.moves);
|
||||
const lastMove = session.moveHistory.length > 0
|
||||
? session.moveHistory[session.moveHistory.length - 1]
|
||||
: null;
|
||||
|
||||
// Determine which color the tutor is playing
|
||||
const tutorColor = userColor === 'white' ? 'black' : 'white';
|
||||
|
||||
// Find last user move and last tutor move
|
||||
const userMoves = session.moveHistory.filter(
|
||||
m => m.color === userColor
|
||||
);
|
||||
const tutorMoves = session.moveHistory.filter(
|
||||
m => m.color === tutorColor
|
||||
);
|
||||
|
||||
const lastUserMove = userMoves.length > 0 ? userMoves[userMoves.length - 1] : null;
|
||||
const lastTutorMove = tutorMoves.length > 0 ? tutorMoves[tutorMoves.length - 1] : null;
|
||||
|
||||
const openingPracticeMode = {
|
||||
openingName: opening.name,
|
||||
openingEco: opening.eco,
|
||||
repertoireMoves,
|
||||
currentMoveIndex: session.moveHistory.length,
|
||||
isInTheory: session.deviationMoveIndex === null,
|
||||
deviationMoveIndex: session.deviationMoveIndex,
|
||||
lastUserMove: lastUserMove ? {
|
||||
from: lastUserMove.uci.substring(0, 2),
|
||||
to: lastUserMove.uci.substring(2, 4),
|
||||
san: lastUserMove.san,
|
||||
color: lastUserMove.color === 'white' ? 'w' : 'b',
|
||||
piece: lastUserMove.san[0].toLowerCase(),
|
||||
flags: '',
|
||||
captured: undefined,
|
||||
promotion: lastUserMove.uci.length > 4 ? lastUserMove.uci[4] : undefined
|
||||
} as any : null,
|
||||
lastTutorMove: lastTutorMove ? {
|
||||
from: lastTutorMove.uci.substring(0, 2),
|
||||
to: lastTutorMove.uci.substring(2, 4),
|
||||
san: lastTutorMove.san,
|
||||
color: lastTutorMove.color === 'white' ? 'w' : 'b',
|
||||
piece: lastTutorMove.san[0].toLowerCase(),
|
||||
flags: '',
|
||||
captured: undefined,
|
||||
promotion: lastTutorMove.uci.length > 4 ? lastTutorMove.uci[4] : undefined
|
||||
} as any : null,
|
||||
currentFeedback: currentFeedback ? {
|
||||
category: currentFeedback.classification.category,
|
||||
evaluationChange: currentFeedback.classification.evaluationChange,
|
||||
theoreticalAlternatives: currentFeedback.classification.theoreticalAlternatives
|
||||
} : null,
|
||||
wikipediaSummary: wikipediaSummary?.extract || undefined
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Main board area */}
|
||||
<div className="lg:col-span-2 space-y-4">
|
||||
{/* Board */}
|
||||
<div
|
||||
className="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-4"
|
||||
role="region"
|
||||
aria-label="Chess board"
|
||||
>
|
||||
<Chessboard
|
||||
key={currentPosition}
|
||||
options={{
|
||||
position: currentPosition,
|
||||
onPieceDrop: ({ sourceSquare, targetSquare }) => {
|
||||
if (!targetSquare) return false;
|
||||
return handlePieceDrop(sourceSquare, targetSquare);
|
||||
},
|
||||
boardOrientation: boardOrientation,
|
||||
darkSquareStyle: { backgroundColor: '#779954' },
|
||||
lightSquareStyle: { backgroundColor: '#e9edcc' },
|
||||
animationDurationInMs: 200,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Move controls */}
|
||||
<div
|
||||
className="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-4"
|
||||
role="region"
|
||||
aria-label="Move history and navigation"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="font-semibold text-gray-900 dark:text-white">Move History</h3>
|
||||
<div className="flex gap-2" role="group" aria-label="Move navigation">
|
||||
<button
|
||||
onClick={() =>
|
||||
navigateToMove(Math.max(0, session.currentMoveIndex - 1))
|
||||
}
|
||||
disabled={session.currentMoveIndex === 0}
|
||||
className="px-3 py-1 text-sm bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded hover:bg-gray-300 dark:hover:bg-gray-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
aria-label="Go to previous move"
|
||||
>
|
||||
← Back
|
||||
</button>
|
||||
<button
|
||||
onClick={() =>
|
||||
navigateToMove(
|
||||
Math.min(moveCount - 1, session.currentMoveIndex + 1)
|
||||
)
|
||||
}
|
||||
disabled={session.currentMoveIndex >= moveCount - 1}
|
||||
className="px-3 py-1 text-sm bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded hover:bg-gray-300 dark:hover:bg-gray-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
aria-label="Go to next move"
|
||||
>
|
||||
Forward →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Move list */}
|
||||
<div
|
||||
className="space-y-2 max-h-[200px] overflow-y-auto"
|
||||
role="list"
|
||||
aria-label="List of moves played"
|
||||
>
|
||||
{moveCount === 0 ? (
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 text-center py-4">
|
||||
No moves yet. Make your first move!
|
||||
</p>
|
||||
) : (
|
||||
session.moveHistory.map((move, index) => (
|
||||
<div
|
||||
key={index}
|
||||
onClick={() => navigateToMove(index)}
|
||||
role="listitem"
|
||||
className={`p-2 rounded cursor-pointer transition-colors ${
|
||||
index === session.currentMoveIndex
|
||||
? 'bg-blue-100 dark:bg-blue-900/30 border border-blue-300 dark:border-blue-700'
|
||||
: 'bg-gray-50 dark:bg-gray-700/50 hover:bg-gray-100 dark:hover:bg-gray-700'
|
||||
}`}
|
||||
aria-label={`Move ${move.moveNumber}${
|
||||
move.color === 'white' ? '.' : '...'
|
||||
} ${move.san}, classified as ${move.classification.category}`}
|
||||
aria-current={index === session.currentMoveIndex ? 'true' : undefined}
|
||||
>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="font-mono text-sm font-semibold">
|
||||
{move.moveNumber}
|
||||
{move.color === 'white' ? '.' : '...'} {move.san}
|
||||
</span>
|
||||
<span
|
||||
className={`text-xs px-2 py-1 rounded ${
|
||||
move.classification.category === 'in-theory'
|
||||
? 'bg-green-100 text-green-800'
|
||||
: move.classification.category === 'playable'
|
||||
? 'bg-yellow-100 text-yellow-800'
|
||||
: 'bg-red-100 text-red-800'
|
||||
}`}
|
||||
>
|
||||
{move.classification.category}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sidebar - tutor and info */}
|
||||
<div className="space-y-4">
|
||||
{/* Tutor Chat */}
|
||||
{apiKey ? (
|
||||
<Tutor
|
||||
game={chess}
|
||||
currentFen={currentPosition}
|
||||
userMove={null} // Will be updated in Phase 2
|
||||
computerMove={null}
|
||||
stockfish={stockfish}
|
||||
evalP0={null}
|
||||
evalP2={null}
|
||||
openingData={[]}
|
||||
missedTactics={[]}
|
||||
onAnalysisComplete={() => {}}
|
||||
apiKey={apiKey}
|
||||
personality={personality}
|
||||
language={language}
|
||||
playerColor={userColor}
|
||||
onCheckComputerMove={() => {}}
|
||||
resignationContext={null}
|
||||
openingPracticeMode={openingPracticeMode}
|
||||
/>
|
||||
) : (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 p-6">
|
||||
<div className="text-center">
|
||||
<div className="text-4xl mb-4">🔑</div>
|
||||
<h3 className="text-lg font-bold text-gray-900 dark:text-white mb-2">
|
||||
API Key Required
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
|
||||
To chat with your coach, please set up your Gemini API key in the settings.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => window.location.href = '/onboarding'}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
Set Up API Key
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Wikipedia summary */}
|
||||
<WikipediaSummary
|
||||
openingName={opening.name}
|
||||
wikipediaSlug={opening.wikipediaSlug}
|
||||
/>
|
||||
|
||||
{/* Session info */}
|
||||
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-4 space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600 dark:text-gray-400">Opening:</span>
|
||||
<span className="font-medium text-gray-900 dark:text-white">{opening.eco}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600 dark:text-gray-400">Moves played:</span>
|
||||
<span className="font-medium text-gray-900 dark:text-white">{moveCount}</span>
|
||||
</div>
|
||||
{session.deviationMoveIndex !== null && (
|
||||
<div className="pt-2 border-t border-gray-300 dark:border-gray-600">
|
||||
<span className="inline-block px-2 py-1 bg-orange-100 dark:bg-orange-900/30 text-orange-800 dark:text-orange-400 rounded text-xs">
|
||||
Off-book since move {session.deviationMoveIndex + 1}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { WikipediaSummary as WikipediaSummaryType } from '@/types/openingTraining';
|
||||
import { getWikipediaSummary } from '@/lib/openingTrainer/wikipediaService';
|
||||
|
||||
interface WikipediaSummaryProps {
|
||||
openingName: string;
|
||||
wikipediaSlug?: string; // Preferred: direct slug from database
|
||||
}
|
||||
|
||||
export default function WikipediaSummary({ openingName, wikipediaSlug }: WikipediaSummaryProps) {
|
||||
const [summary, setSummary] = useState<WikipediaSummaryType | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchSummary();
|
||||
}, [openingName, wikipediaSlug]);
|
||||
|
||||
const fetchSummary = async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// Use slug if provided, otherwise fall back to name lookup
|
||||
const data = await getWikipediaSummary(openingName, wikipediaSlug);
|
||||
|
||||
if (!data) {
|
||||
setError('No Wikipedia article found for this opening');
|
||||
setSummary(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setSummary(data);
|
||||
} catch (err) {
|
||||
setError('Failed to load opening background information');
|
||||
setSummary(null);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="w-4 h-4 border-2 border-blue-600 dark:border-blue-400 border-t-transparent rounded-full animate-spin"></div>
|
||||
<p className="text-sm text-blue-800 dark:text-blue-300">Loading opening background...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !summary) {
|
||||
return (
|
||||
<div className="bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
No background information available for this opening.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4 space-y-3">
|
||||
<div className="flex justify-between items-start">
|
||||
<h3 className="font-semibold text-blue-900 dark:text-blue-300">{summary.title}</h3>
|
||||
<a
|
||||
href={summary.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs text-blue-600 dark:text-blue-400 hover:underline"
|
||||
>
|
||||
Wikipedia ↗
|
||||
</a>
|
||||
</div>
|
||||
<p className="text-sm text-gray-700 dark:text-gray-300 leading-relaxed">{summary.extract}</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
Source: Wikipedia (cached {new Date(summary.fetchedAt).toLocaleDateString()})
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+236
-11
@@ -1,7 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { Stockfish, StockfishEvaluation } from "@/lib/stockfish";
|
||||
import { StockfishEvaluation } from "@/lib/stockfish";
|
||||
import { ChessEngine } from "@/lib/engine";
|
||||
import { Chess, Move } from "chess.js";
|
||||
import { getGenAIModel } from "@/lib/gemini";
|
||||
import { ChatSession } from "@google/generative-ai";
|
||||
@@ -16,13 +17,16 @@ import { SupportedLanguage } from '@/lib/i18n/translations';
|
||||
import { DetectedTactic } from '@/lib/tacticDetection';
|
||||
import { useDebug } from '@/contexts/DebugContext';
|
||||
import { MoveHistoryItem } from './GameOverModal';
|
||||
import { parseGeminiError, GeminiErrorInfo, isGeminiError } from '@/lib/geminiErrorHandler';
|
||||
import { GeminiErrorModal } from './GeminiErrorModal';
|
||||
import { getApiKeyInfo } from '@/lib/apiKeyHelper';
|
||||
|
||||
interface TutorProps {
|
||||
game: Chess;
|
||||
currentFen: string;
|
||||
userMove: Move | null;
|
||||
computerMove: Move | null;
|
||||
stockfish: Stockfish | null;
|
||||
stockfish: ChessEngine | null;
|
||||
evalP0: StockfishEvaluation | null;
|
||||
evalP2: StockfishEvaluation | null;
|
||||
openingData: OpeningMetadata[];
|
||||
@@ -54,6 +58,22 @@ interface TutorProps {
|
||||
bestStreak: number;
|
||||
};
|
||||
};
|
||||
openingPracticeMode?: {
|
||||
openingName: string;
|
||||
openingEco: string;
|
||||
repertoireMoves: string[]; // Full sequence from opening database
|
||||
currentMoveIndex: number;
|
||||
isInTheory: boolean;
|
||||
deviationMoveIndex: number | null;
|
||||
lastUserMove: Move | null;
|
||||
lastTutorMove: Move | null;
|
||||
currentFeedback: {
|
||||
category: 'in-theory' | 'playable' | 'weak';
|
||||
evaluationChange: number;
|
||||
theoreticalAlternatives: string[];
|
||||
} | null;
|
||||
wikipediaSummary?: string; // Optional Wikipedia context
|
||||
};
|
||||
}
|
||||
|
||||
interface Message {
|
||||
@@ -62,11 +82,12 @@ interface Message {
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export function Tutor({ game, currentFen, userMove, computerMove, stockfish, evalP0, evalP2, openingData, missedTactics, onAnalysisComplete, apiKey, personality, language, playerColor, onCheckComputerMove, resignationContext, tacticalPracticeMode }: TutorProps) {
|
||||
export function Tutor({ game, currentFen, userMove, computerMove, stockfish, evalP0, evalP2, openingData, missedTactics, onAnalysisComplete, apiKey, personality, language, playerColor, onCheckComputerMove, resignationContext, tacticalPracticeMode, openingPracticeMode }: TutorProps) {
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
const [input, setInput] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [chatSession, setChatSession] = useState<ChatSession | null>(null);
|
||||
const [geminiError, setGeminiError] = useState<GeminiErrorInfo | null>(null);
|
||||
const messagesContainerRef = useRef<HTMLDivElement>(null);
|
||||
const { addEntry } = useDebug();
|
||||
|
||||
@@ -84,15 +105,59 @@ export function Tutor({ game, currentFen, userMove, computerMove, stockfish, eva
|
||||
// Track the current puzzle to detect when it changes
|
||||
const currentPuzzleRef = useRef<string | null>(null);
|
||||
|
||||
// Track last opening moves to detect when new moves are made
|
||||
const lastUserMoveRef = useRef<string | null>(null);
|
||||
const lastTutorMoveRef = useRef<string | null>(null);
|
||||
|
||||
// Initialize chat session with Personality System Prompt (only once per pattern type)
|
||||
useEffect(() => {
|
||||
if (apiKey) {
|
||||
const model = getGenAIModel(apiKey, "gemini-2.5-flash");
|
||||
|
||||
// Build system prompt based on mode
|
||||
// NOTE: For tactical practice, we don't include the specific puzzle solution in the system prompt
|
||||
// Instead, we'll send it as a message when the puzzle changes
|
||||
const systemPrompt = tacticalPracticeMode ? `
|
||||
const systemPrompt = openingPracticeMode ? `
|
||||
You are a Chess Tutor helping a student learn the "${openingPracticeMode.openingName}" opening.
|
||||
You must strictly follow the personality defined below.
|
||||
|
||||
PERSONALITY:
|
||||
${personality.systemPrompt}
|
||||
|
||||
${openingPracticeMode.wikipediaSummary ? `OPENING BACKGROUND (from Wikipedia):
|
||||
${openingPracticeMode.wikipediaSummary}
|
||||
|
||||
Use this background to enrich your explanations, but keep responses concise.
|
||||
` : ''}
|
||||
|
||||
YOUR ROLE:
|
||||
You are BOTH the opponent AND the tutor in this opening training session.
|
||||
|
||||
1. OPPONENT: You are playing as ${tutorColorName} in the ${openingPracticeMode.openingName}.
|
||||
- You will make moves from the opening repertoire
|
||||
- Refer to your moves naturally ("I played e5", "My response is...")
|
||||
|
||||
2. TUTOR: You are teaching the student this opening.
|
||||
- The student is playing as ${playerColorName}
|
||||
- Explain the IDEAS behind each move, not just the moves themselves
|
||||
- When the student asks for help, ALWAYS provide guidance
|
||||
- When the student stays in theory, praise them and explain what's happening
|
||||
- When the student deviates, explain why the repertoire move is better
|
||||
|
||||
YOUR RESPONSIBILITIES:
|
||||
1. WELCOME: Start with a warm greeting and brief explanation of the ${openingPracticeMode.openingName}
|
||||
2. GUIDANCE: After each move, explain the ideas and plans
|
||||
3. ENCOURAGEMENT: Keep the student motivated while learning
|
||||
4. DEVIATION HANDLING: When the student leaves theory, gently correct them
|
||||
5. ANSWERING QUESTIONS: Always help when the student asks
|
||||
|
||||
CRITICAL RULES:
|
||||
- Be encouraging and supportive
|
||||
- Explain IDEAS and PLANS, not just moves
|
||||
- Keep responses concise (2-4 sentences)
|
||||
- Do NOT be repetitive - vary your language
|
||||
- You MUST respond in the following language: ${language.toUpperCase()}
|
||||
- NEVER mention "Stockfish", "engine", "computer", or "AI"
|
||||
- When you make a move, explain WHY briefly
|
||||
` : tacticalPracticeMode ? `
|
||||
You are a Chess Coach helping a student practice tactical patterns.
|
||||
You must strictly follow the personality defined below.
|
||||
|
||||
@@ -161,7 +226,9 @@ CRITICAL RULES:
|
||||
},
|
||||
{
|
||||
role: "model",
|
||||
parts: [{ text: tacticalPracticeMode
|
||||
parts: [{ text: openingPracticeMode
|
||||
? `Understood. I will teach you the ${openingPracticeMode.openingName} opening in ${language}. I am both your opponent and your tutor. I'll explain the ideas behind each move and help you learn this opening.`
|
||||
: tacticalPracticeMode
|
||||
? `Understood. I will help you practice ${tacticalPracticeMode.patternName} in ${language}. I'll provide hints and encouragement while maintaining my personality.`
|
||||
: `Understood. I am both the opponent (${tutorColorName}) AND your tutor. I will compete against you while teaching you to improve. I will speak in ${language} and never mention engines or AI. When you ask for help, I will always provide guidance - that's my purpose.`
|
||||
}]
|
||||
@@ -171,7 +238,18 @@ CRITICAL RULES:
|
||||
setChatSession(session);
|
||||
|
||||
// Get initial greeting in the selected language
|
||||
const greetingPrompt = tacticalPracticeMode
|
||||
const greetingPrompt = openingPracticeMode
|
||||
? `Welcome the student to learn the ${openingPracticeMode.openingName}. Briefly explain the key ideas of this opening (in 2-3 sentences).
|
||||
|
||||
IMPORTANT:
|
||||
- Clarify that YOU are playing as ${tutorColorName} and the STUDENT is playing as ${playerColorName}
|
||||
- If the student is White, make it clear THEY will make the first move, not you
|
||||
- If the student is Black, explain you'll make the first move and then they'll respond
|
||||
- Don't claim you'll make a move that the student should be making
|
||||
- Be encouraging and clear about the game flow
|
||||
|
||||
Keep it in ${language}.`
|
||||
: tacticalPracticeMode
|
||||
? `Welcome the student to practice ${tacticalPracticeMode.patternName}. Briefly explain what this tactical pattern is (in 1-2 sentences). Keep it encouraging and in ${language}.`
|
||||
: `Introduce yourself briefly to start our game. Keep it short and in ${language}.`;
|
||||
|
||||
@@ -180,14 +258,23 @@ CRITICAL RULES:
|
||||
setMessages([{ role: "model", text: greetingText, timestamp: Date.now() }]);
|
||||
}).catch(err => {
|
||||
console.error("Failed to get greeting:", err);
|
||||
|
||||
// Check if it's a Gemini API error
|
||||
if (isGeminiError(err)) {
|
||||
const errorInfo = parseGeminiError(err);
|
||||
setGeminiError(errorInfo);
|
||||
}
|
||||
|
||||
// Fallback greeting
|
||||
const fallbackText = tacticalPracticeMode
|
||||
const fallbackText = openingPracticeMode
|
||||
? `Hello! Let's learn the ${openingPracticeMode.openingName} together!`
|
||||
: tacticalPracticeMode
|
||||
? `Hello! Let's practice ${tacticalPracticeMode.patternName} together!`
|
||||
: `Hello! I am ${personality.name}. Let's play!`;
|
||||
setMessages([{ role: "model", text: fallbackText, timestamp: Date.now() }]);
|
||||
});
|
||||
}
|
||||
}, [apiKey, personality, language, playerColor, patternName]);
|
||||
}, [apiKey, personality, language, playerColor, patternName, openingPracticeMode]);
|
||||
// NOTE: Removed solutionMoveKey from dependencies - we don't want to reset chat when puzzle changes
|
||||
|
||||
// Notify tutor about new puzzle (without resetting chat)
|
||||
@@ -230,9 +317,112 @@ Acknowledge this new puzzle briefly (1 sentence) and encourage the student to fi
|
||||
setMessages(prev => [...prev, { role: "model", text: responseText, timestamp: Date.now() }]);
|
||||
}).catch(err => {
|
||||
console.error("Failed to notify about new puzzle:", err);
|
||||
|
||||
// Check if it's a Gemini API error
|
||||
if (isGeminiError(err)) {
|
||||
const errorInfo = parseGeminiError(err);
|
||||
setGeminiError(errorInfo);
|
||||
}
|
||||
});
|
||||
}, [solutionMoveKey, chatSession, tacticalPracticeMode, currentFen, language]);
|
||||
|
||||
// Automatic commentary for opening practice mode
|
||||
useEffect(() => {
|
||||
if (!chatSession || !openingPracticeMode) return;
|
||||
|
||||
const userMoveKey = openingPracticeMode.lastUserMove
|
||||
? `${openingPracticeMode.lastUserMove.san}-${openingPracticeMode.currentMoveIndex}`
|
||||
: null;
|
||||
const tutorMoveKey = openingPracticeMode.lastTutorMove
|
||||
? `${openingPracticeMode.lastTutorMove.san}-${openingPracticeMode.currentMoveIndex}`
|
||||
: null;
|
||||
|
||||
// Check if user made a new move
|
||||
if (userMoveKey && userMoveKey !== lastUserMoveRef.current) {
|
||||
lastUserMoveRef.current = userMoveKey;
|
||||
|
||||
// Generate commentary about user's move
|
||||
const feedback = openingPracticeMode.currentFeedback;
|
||||
const moveCommentary = `
|
||||
[SYSTEM TRIGGER: user_move_in_opening]
|
||||
|
||||
The student just played: ${openingPracticeMode.lastUserMove!.san}
|
||||
Move category: ${feedback?.category || 'unknown'}
|
||||
Position status: ${openingPracticeMode.isInTheory ? 'In theory' : 'Deviated from repertoire'}
|
||||
${feedback?.evaluationChange !== undefined ? `Evaluation change: ${feedback.evaluationChange.toFixed(2)}` : ''}
|
||||
${feedback?.theoreticalAlternatives && feedback.theoreticalAlternatives.length > 0 ? `Theory suggested: ${feedback.theoreticalAlternatives.join(', ')}` : ''}
|
||||
|
||||
INSTRUCTIONS:
|
||||
${openingPracticeMode.isInTheory
|
||||
? `- The student is following the repertoire correctly - praise them briefly
|
||||
- Explain the key idea behind this move (1-2 sentences)
|
||||
- If you're about to make the next move, you can mention it naturally`
|
||||
: `- The student deviated from theory
|
||||
- Gently point out what the repertoire move was
|
||||
- Explain why the repertoire move is preferred
|
||||
- Ask if they want to try again or continue exploring`}
|
||||
- Keep it concise (2-3 sentences max)
|
||||
- Stay in ${language}
|
||||
- Maintain your personality
|
||||
`.trim();
|
||||
|
||||
chatSession.sendMessage(moveCommentary).then(result => {
|
||||
const response = result.response.text();
|
||||
setMessages(prev => [...prev, { role: "model", text: response, timestamp: Date.now() }]);
|
||||
}).catch(err => {
|
||||
console.error("Failed to generate user move commentary:", err);
|
||||
if (isGeminiError(err)) {
|
||||
setGeminiError(parseGeminiError(err));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Check if tutor made a new move
|
||||
if (tutorMoveKey && tutorMoveKey !== lastTutorMoveRef.current) {
|
||||
lastTutorMoveRef.current = tutorMoveKey;
|
||||
|
||||
// Generate commentary about tutor's move
|
||||
const tutorCommentary = `
|
||||
[SYSTEM TRIGGER: tutor_move_in_opening]
|
||||
|
||||
I just played: ${openingPracticeMode.lastTutorMove!.san}
|
||||
Current position FEN: ${currentFen}
|
||||
Progress: ${openingPracticeMode.currentMoveIndex}/${openingPracticeMode.repertoireMoves.length} moves
|
||||
|
||||
INSTRUCTIONS:
|
||||
- Explain WHY you played this move (the idea behind it)
|
||||
- Mention what it accomplishes (controls center, develops, creates threat, etc.)
|
||||
- If relevant, mention what the student should think about for their next move
|
||||
- Keep it conversational and in character
|
||||
- 2-3 sentences max
|
||||
- Respond in ${language}
|
||||
|
||||
Remember: You are both the opponent AND the tutor. Explain your move as if you're teaching.
|
||||
`.trim();
|
||||
|
||||
// Add small delay before tutor explains their move
|
||||
setTimeout(() => {
|
||||
chatSession.sendMessage(tutorCommentary).then(result => {
|
||||
const response = result.response.text();
|
||||
setMessages(prev => [...prev, { role: "model", text: response, timestamp: Date.now() }]);
|
||||
}).catch(err => {
|
||||
console.error("Failed to generate tutor move commentary:", err);
|
||||
if (isGeminiError(err)) {
|
||||
setGeminiError(parseGeminiError(err));
|
||||
}
|
||||
});
|
||||
}, 300); // Brief delay so the move appears first, then the explanation
|
||||
}
|
||||
}, [
|
||||
chatSession,
|
||||
openingPracticeMode?.lastUserMove?.san,
|
||||
openingPracticeMode?.lastTutorMove?.san,
|
||||
openingPracticeMode?.currentMoveIndex,
|
||||
openingPracticeMode?.isInTheory,
|
||||
currentFen,
|
||||
language
|
||||
]);
|
||||
|
||||
// Scroll chat container to bottom (not the whole page)
|
||||
useEffect(() => {
|
||||
if (messagesContainerRef.current) {
|
||||
@@ -583,7 +773,33 @@ INSTRUCTIONS:
|
||||
setMessages(prev => [...prev, { role: "model", text: textResponse, timestamp: Date.now() }]);
|
||||
} catch (error) {
|
||||
console.error("Chat Error:", error);
|
||||
setMessages(prev => [...prev, { role: "model", text: "Sorry, I encountered an error.", timestamp: Date.now() }]);
|
||||
|
||||
// Check if it's a Gemini API error
|
||||
if (isGeminiError(error)) {
|
||||
const errorInfo = parseGeminiError(error);
|
||||
setGeminiError(errorInfo);
|
||||
|
||||
// Show a brief error message in chat
|
||||
if (errorInfo.isQuotaError) {
|
||||
setMessages(prev => [...prev, {
|
||||
role: "model",
|
||||
text: "⚠️ API quota exceeded. Please check the error message for details.",
|
||||
timestamp: Date.now()
|
||||
}]);
|
||||
} else {
|
||||
setMessages(prev => [...prev, {
|
||||
role: "model",
|
||||
text: "⚠️ I encountered an error. Please try again.",
|
||||
timestamp: Date.now()
|
||||
}]);
|
||||
}
|
||||
} else {
|
||||
setMessages(prev => [...prev, {
|
||||
role: "model",
|
||||
text: "Sorry, I encountered an error.",
|
||||
timestamp: Date.now()
|
||||
}]);
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
@@ -748,6 +964,15 @@ INSTRUCTIONS:
|
||||
<Send size={20} />
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* Gemini Error Modal */}
|
||||
{geminiError && (
|
||||
<GeminiErrorModal
|
||||
error={geminiError}
|
||||
apiKeyInfo={getApiKeyInfo()}
|
||||
onClose={() => setGeminiError(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user