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
+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>
);
}