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:
Stefan
2025-12-07 18:40:58 +01:00
parent 52dd32eb15
commit 002ed92bea
57 changed files with 7015 additions and 234280 deletions
+150
View File
@@ -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
View File
@@ -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>
);
}