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,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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user