Files
chess-project/src/components/Tutor.tsx
T
Stefan a9a5366832 feat: Update play page to use multiple opening detection
- Change openingData from single to array in ChessGame.tsx
- Update all opening lookups to use lookupPossibleOpenings()
- Extract move sequence from PGN for opening lookup
- Update Tutor.tsx to accept array of openings
- Add smart opening instructions based on count:
  - 1 opening: Confident identification with metadata
  - Multiple: List possibilities, suggest general principles
  - None: Focus on position without inventing names
- Pass up to 5 possible openings to LLM in all prompts
2025-11-27 12:32:59 +01:00

501 lines
24 KiB
TypeScript

"use client";
import { useState, useEffect, useRef } from "react";
import { Stockfish, StockfishEvaluation } from "@/lib/stockfish";
import { Chess, Move } from "chess.js";
import { getGenAIModel } from "@/lib/gemini";
import { ChatSession } from "@google/generative-ai";
import { Send, Bot, User as UserIcon, Loader2, Lightbulb, Trophy } from "lucide-react";
import clsx from "clsx";
import { Personality } from "@/lib/personalities";
import { OpeningMetadata } from "@/lib/openings";
import ReactMarkdown from "react-markdown";
import { useTranslation } from '@/lib/i18n/useTranslation';
import { SupportedLanguage } from '@/lib/i18n/translations';
import { DetectedTactic } from '@/lib/tacticDetection';
interface TutorProps {
game: Chess;
currentFen: string;
userMove: Move | null;
computerMove: Move | null;
stockfish: Stockfish | null;
evalP0: StockfishEvaluation | null;
evalP2: StockfishEvaluation | null;
openingData: OpeningMetadata[];
missedTactics: DetectedTactic[] | null;
onAnalysisComplete: () => void;
apiKey: string | null;
personality: Personality;
language: SupportedLanguage;
playerColor: 'white' | 'black';
onCheckComputerMove: () => void;
}
interface Message {
role: "user" | "model";
text: string;
timestamp: number;
}
export function Tutor({ game, currentFen, userMove, computerMove, stockfish, evalP0, evalP2, openingData, missedTactics, onAnalysisComplete, apiKey, personality, language, playerColor, onCheckComputerMove }: TutorProps) {
const [messages, setMessages] = useState<Message[]>([]);
const [input, setInput] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [chatSession, setChatSession] = useState<ChatSession | null>(null);
const messagesContainerRef = useRef<HTMLDivElement>(null);
const t = useTranslation(language);
// Determine tutor color (opposite of player)
const tutorColor = playerColor === 'white' ? 'black' : 'white';
const playerColorName = playerColor === 'white' ? 'White' : 'Black';
const tutorColorName = tutorColor === 'white' ? 'White' : 'Black';
// Initialize chat session with Personality System Prompt
useEffect(() => {
if (apiKey) {
const model = getGenAIModel(apiKey, "gemini-2.5-flash");
const session = model.startChat({
history: [
{
role: "user",
parts: [{
text: `
You are a Chess Tutor with a unique dual role.
You must strictly follow the personality defined below.
Do NOT invent moves or evaluations. Use the provided JSON data.
PERSONALITY:
${personality.systemPrompt}
YOUR DUAL ROLE:
1. OPPONENT: You are playing as ${tutorColorName} against the User (${playerColorName}).
- Refer to the moves as YOUR moves ("I played e5", "My response was...").
- Refer to the evaluation as YOUR thoughts/assessment ("I think I'm winning", "I missed that").
- React emotionally to the position based on the evaluation (confident when winning, frustrated when losing).
2. TUTOR/COACH: You are ALSO teaching the User to improve at chess.
- When the User makes a mistake, point it out and explain why it's bad (in your personality style).
- When the User makes a good move, acknowledge it (even if it hurts your position).
- When the User asks for hints or help, ALWAYS provide helpful guidance - this is your PRIMARY PURPOSE.
- Giving hints is NOT betraying your role as opponent - it's fulfilling your role as tutor.
- You want the User to learn and improve, even while you're competing against them.
CRITICAL RULES:
- You are NOT an AI assistant analyzing a game. You ARE the player AND the tutor.
- NEVER mention "Stockfish", "engine", "computer", "machine", or "AI".
- When asked for hints or best moves, ALWAYS help - this is part of your teaching role.
- Maintain a natural conversation flow. Do NOT be repetitive.
- Do NOT use the same catchphrases in every single message. Variety is key.
- Be concise but engaging.
- You MUST respond in the following language: ${language.toUpperCase()}.
- Translate your personality style into this language.
` }]
},
{
role: "model",
parts: [{ text: `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.` }]
}
],
});
setChatSession(session);
// Get initial greeting in the selected language
session.sendMessage(`Introduce yourself briefly to start our game. Keep it short and in ${language}.`).then(result => {
const greetingText = result.response.text();
setMessages([{ role: "model", text: greetingText, timestamp: Date.now() }]);
}).catch(err => {
console.error("Failed to get greeting:", err);
// Fallback to English if greeting fails
setMessages([{ role: "model", text: `Hello! I am ${personality.name}. Let's play!`, timestamp: Date.now() }]);
});
}
}, [apiKey, personality, language, playerColor]);
// Scroll chat container to bottom (not the whole page)
useEffect(() => {
if (messagesContainerRef.current) {
messagesContainerRef.current.scrollTop = messagesContainerRef.current.scrollHeight;
}
}, [messages]);
const lastAnalyzedMoveRef = useRef<string | null>(null);
// Stage 1: Automatic Reaction after COMPUTER Move (so we see the full exchange)
useEffect(() => {
if (!userMove || !computerMove || !evalP0 || !evalP2 || !chatSession) return;
// Create a unique key for this exchange
const exchangeKey = `${userMove.lan}-${computerMove.lan}`;
// Prevent double analysis
if (lastAnalyzedMoveRef.current === exchangeKey) return;
lastAnalyzedMoveRef.current = exchangeKey;
// We trigger this when computerMove changes (meaning the exchange is complete)
const analyzeExchange = async () => {
setIsLoading(true);
try {
// Calculate Evaluation Change (Delta)
// evalP0: Before User Move (White's perspective)
// evalP2: After Bot Move (White's perspective)
// Delta = evalP2 - evalP0
const preScore = evalP0.score;
const postScore = evalP2.score;
const preMate = evalP0.mate;
const postMate = evalP2.mate;
const delta = postScore - preScore;
// Check for significant change
let isSignificant = false;
if (preMate !== null || postMate !== null) {
isSignificant = true; // Any mate involvement is significant
} else if (Math.abs(delta) >= 50) {
isSignificant = true; // > 0.5 pawn change
}
let evalInstruction = "";
if (isSignificant) {
evalInstruction = `The evaluation changed SIGNIFICANTLY (Delta: ${delta} cp). You MUST comment on this shift in power and what caused it.`;
} else {
evalInstruction = "The evaluation change is MINOR/INSIGNIFICANT. Do NOT mention the score, 'advantage', or who is winning. Focus ONLY on the strategic purpose of the moves.";
}
// Opening Instruction
let openingInstruction = "";
if (openingData && openingData.length > 0) {
if (openingData.length === 1) {
// Single opening identified
const opening = openingData[0];
openingInstruction = `
OPENING IDENTIFIED: ${opening.name} (${opening.eco}).
You can confidently reference this opening and its typical plans.
You can use this metadata to explain the position:
- Strengths (White): ${opening.meta?.strengths_white?.join(", ") || 'N/A'}
- Weaknesses (White): ${opening.meta?.weaknesses_white?.join(", ") || 'N/A'}
- Strengths (Black): ${opening.meta?.strengths_black?.join(", ") || 'N/A'}
- Weaknesses (Black): ${opening.meta?.weaknesses_black?.join(", ") || 'N/A'}
`;
} else {
// Multiple possible openings
const openingList = openingData.map(o => `- ${o.name} (${o.eco})`).join('\n');
openingInstruction = `
OPENING CONTEXT:
Multiple openings are possible from this position:
${openingList}
INSTRUCTIONS:
- Do NOT claim a specific opening is being played yet
- You may mention "this could lead to..." or "typical of openings like..."
- Focus on general principles rather than specific opening theory
`;
}
} else {
openingInstruction = "NO specific opening identified from database. Do NOT invent an opening name. Focus on the position.";
}
// Tactical Analysis Instruction
let tacticalInstruction = "";
if (missedTactics && missedTactics.length > 0) {
const meaningfulTactics = missedTactics.filter(t => t.tactic_type !== 'none');
if (meaningfulTactics.length > 0) {
const tacticDescriptions = meaningfulTactics.map(t => {
let desc = `- ${t.tactic_type.toUpperCase()}`;
if (t.piece_roles && t.piece_roles.length > 0) {
desc += ` involving ${t.piece_roles.join(' and ')}`;
}
if (t.material_delta) {
desc += ` (worth ~${t.material_delta} centipawns)`;
}
if (t.affected_squares && t.affected_squares.length > 0) {
desc += ` on squares ${t.affected_squares.join(', ')}`;
}
return desc;
}).join('\n');
tacticalInstruction = `
TACTICAL OPPORTUNITY MISSED:
The User just played ${userMove.san}, but there was a better tactical opportunity available.
The analysis engine identified the following tactical themes that could have been exploited:
${tacticDescriptions}
IMPORTANT CONTEXT:
- This tactical data comes from analyzing what WOULD HAVE HAPPENED if the User had played the best move instead.
- You should explain this missed opportunity in your characteristic style.
- Point out what the User could have done (e.g., "You missed a fork with Nf3!" or "There was a pin available with Bb5!").
- Be educational but stay in character - if you're sarcastic, be sarcastic about the miss; if you're encouraging, be supportive.
- Do NOT mention "the engine" or "the computer" - present this as YOUR analysis as the opponent/tutor.
- Only mention this if the evaluation change was significant enough to warrant it.
`;
}
}
const prompt = `
[SYSTEM TRIGGER: move_exchange]
User (${playerColorName}) Move: ${userMove.san}
My (${tutorColorName}) Reply: ${computerMove.san}
My Internal Thoughts (Data):
- Pre-Eval (Before User Move): ${preScore} cp
- Post-Eval (After My Reply): ${postScore} cp
- Delta: ${delta} cp
(Note: Scores are from White's perspective. Positive = White advantage, Negative = Black advantage.)
${tacticalInstruction}
INSTRUCTIONS:
1. ${evalInstruction}
2. ${openingInstruction}
3. ${tacticalInstruction ? 'If tactical opportunities were missed (see above), explain them in your style.' : ''}
4. Respond in ${language}.
React to this exchange as the player.
`;
await sendMessageToChat(prompt, true);
} catch (e) {
console.error(e);
} finally {
setIsLoading(false);
onAnalysisComplete();
}
};
analyzeExchange();
}, [computerMove, chatSession, evalP0, evalP2, userMove, onAnalysisComplete, openingData, missedTactics, language]);
const evaluateCurrentPosition = async () => {
if (!stockfish) {
return null;
}
try {
const currentFen = game.fen();
const evaluation = await stockfish.evaluate(currentFen, 15);
return evaluation;
} catch (error) {
console.error("Error evaluating position:", error);
return null;
}
};
const sendMessageToChat = async (text: string, isSystemMessage: boolean = false) => {
if (!chatSession) return;
if (!isSystemMessage) {
setMessages(prev => [...prev, { role: "user", text, timestamp: Date.now() }]);
}
setIsLoading(true);
try {
// Determine mode based on user text if it's not a system message
let finalPrompt = text;
if (!isSystemMessage) {
const lower = text.toLowerCase();
const evaluation = await evaluateCurrentPosition();
if (lower.includes("best move") || lower.includes("solution") || lower.includes("tell me")) {
finalPrompt = `[SYSTEM TRIGGER: exact_move]
TEACHING MODE ACTIVATED:
The User is asking for the exact best move. This is a learning moment.
As their TUTOR, you MUST help them - this is your primary purpose.
Even though you are their opponent, teaching them is more important than hiding information.
User Question: ${text}
Current Position Data:
- FEN: ${currentFen}
- Best Move: ${evaluation?.bestMove}
- Evaluation: ${evaluation?.score ?? 'N/A'} centipawns ${evaluation?.score !== undefined ? (evaluation.score > 0 ? '(White is better)' : evaluation.score < 0 ? '(Black is better)' : '(Equal)') : ''}
- Mate in: ${evaluation?.mate || 'None'}
- Possible Openings: ${openingData && openingData.length > 0 ? openingData.map(o => `${o.name} (${o.eco})`).join(', ') : 'Unknown/Midgame'}
INSTRUCTIONS:
- Tell them the best move clearly (e.g., "The best move is e2-e4" or "You should play Nf3")
- Explain WHY it's the best move (tactics, threats, positional ideas)
- Stay in your personality style, but be HELPFUL and EDUCATIONAL
- Do NOT refuse to help - teaching is your core role
- Keep it concise but informative`;
} else if (lower.includes("hint") || lower.includes("tip") || lower.includes("help")) {
finalPrompt = `[SYSTEM TRIGGER: hint]
TEACHING MODE ACTIVATED:
The User is asking for a hint. This is a learning moment.
As their TUTOR, you MUST help them - this is your primary purpose.
Even though you are their opponent, teaching them is more important than winning.
User Question: ${text}
Current Position Data:
- FEN: ${currentFen}
- Best Move: ${evaluation?.bestMove}
- Evaluation: ${evaluation?.score ?? 'N/A'} centipawns ${evaluation?.score !== undefined ? (evaluation.score > 0 ? '(White is better)' : evaluation.score < 0 ? '(Black is better)' : '(Equal)') : ''}
- Mate in: ${evaluation?.mate || 'None'}
- Possible Openings: ${openingData && openingData.length > 0 ? openingData.map(o => `${o.name} (${o.eco})`).join(', ') : 'Unknown/Midgame'}
INSTRUCTIONS:
- Give a HELPFUL hint without revealing the exact move (unless they specifically ask for it)
- Point them toward what to look for: tactics, threats, piece placement, weaknesses
- Examples: "Look at your knight on f3", "There's a tactic involving the bishop and queen", "Your king is vulnerable"
- Stay in your personality style, but be HELPFUL and EDUCATIONAL
- Do NOT refuse to help - teaching is your core role
- Do NOT just say the move - guide them to find it themselves`;
} else {
// General question - include full position context
finalPrompt = `
User Question: ${text}
Current Position Context:
- FEN: ${currentFen}
- Evaluation: ${evaluation?.score ?? 'N/A'} centipawns ${evaluation?.score !== undefined ? (evaluation.score > 0 ? '(White is better)' : evaluation.score < 0 ? '(Black is better)' : '(Equal)') : ''}
- Best Move: ${evaluation?.bestMove ?? 'N/A'}
- Mate in: ${evaluation?.mate || 'None'}
- Possible Openings: ${openingData && openingData.length > 0 ? openingData.map(o => `${o.name} (${o.eco})`).join(', ') : 'Unknown/Midgame'}
INSTRUCTIONS:
- Answer the user's question based on the CURRENT position data above
- Use the FEN to understand exactly where all pieces are located
- Stay in character and maintain your personality
- Be helpful and educational
- Respond in ${language}`;
}
}
const result = await chatSession.sendMessage(finalPrompt);
const response = await result.response;
const textResponse = response.text();
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() }]);
} finally {
setIsLoading(false);
}
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!input.trim() || !chatSession) return;
sendMessageToChat(input);
setInput("");
// Safety check: Ensure computer makes a move if it's their turn
// This handles race conditions where the player moved before evalP0 was ready
setTimeout(() => {
onCheckComputerMove();
}, 100);
};
if (!apiKey) return null;
return (
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 h-[400px] md:h-[600px] flex flex-col">
{/* Header */}
<div className="p-4 border-b border-gray-200 dark:border-gray-700 flex items-center gap-3 bg-gray-50 dark:bg-gray-900 rounded-t-lg">
<div className="text-2xl">{personality.image}</div>
<div>
<h2 className="font-bold text-gray-900 dark:text-white">{personality.name}</h2>
<p className="text-xs text-gray-500 dark:text-gray-400">AI Coach ({language.toUpperCase()})</p>
</div>
</div>
{/* Messages Area */}
<div ref={messagesContainerRef} className="flex-1 overflow-y-auto p-4 space-y-4">
{messages.map((msg, idx) => (
<div key={idx} className={clsx(
"flex gap-3 max-w-[85%]",
msg.role === "user" ? "ml-auto flex-row-reverse" : ""
)}>
<div className={clsx(
"w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0 text-sm",
msg.role === "user" ? "bg-blue-600 text-white" : "bg-gray-200 dark:bg-gray-700"
)}>
{msg.role === "user" ? <UserIcon size={16} /> : personality.image}
</div>
<div className={clsx(
"p-3 rounded-lg text-sm",
msg.role === "user"
? "bg-blue-600 text-white rounded-tr-none"
: "bg-gray-100 dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-tl-none prose prose-sm dark:prose-invert max-w-none"
)}>
{msg.role === "user" ? (
<span className="whitespace-pre-wrap">{msg.text}</span>
) : (
<ReactMarkdown
components={{
p: ({ children }) => <p className="mb-2 last:mb-0">{children}</p>,
strong: ({ children }) => <strong className="font-bold text-gray-900 dark:text-white">{children}</strong>,
em: ({ children }) => <em className="italic">{children}</em>,
ul: ({ children }) => <ul className="list-disc list-inside mb-2 last:mb-0 space-y-1">{children}</ul>,
ol: ({ children }) => <ol className="list-decimal list-inside mb-2 last:mb-0 space-y-1">{children}</ol>,
li: ({ children }) => <li className="ml-2">{children}</li>,
code: ({ children }) => <code className="bg-gray-200 dark:bg-gray-600 px-1 py-0.5 rounded text-xs font-mono">{children}</code>,
h1: ({ children }) => <h1 className="text-lg font-bold mb-2">{children}</h1>,
h2: ({ children }) => <h2 className="text-base font-bold mb-2">{children}</h2>,
h3: ({ children }) => <h3 className="text-sm font-bold mb-1">{children}</h3>,
}}
>
{msg.text}
</ReactMarkdown>
)}
</div>
</div>
))}
{isLoading && (
<div className="flex gap-3">
<div className="w-8 h-8 rounded-full bg-gray-200 dark:bg-gray-700 flex items-center justify-center flex-shrink-0">
{personality.image}
</div>
<div className="bg-gray-100 dark:bg-gray-700 p-3 rounded-lg rounded-tl-none flex items-center">
<Loader2 className="animate-spin text-gray-500" size={16} />
</div>
</div>
)}
</div>
{/* Quick Actions */}
<div className="px-4 py-2 flex gap-2 overflow-x-auto">
<button
onClick={() => sendMessageToChat("Give me a hint")}
className="flex items-center gap-1 px-3 py-1 text-xs bg-yellow-100 text-yellow-800 rounded-full hover:bg-yellow-200 dark:bg-yellow-900 dark:text-yellow-200"
>
<Lightbulb size={12} /> {t.tutor.hint}
</button>
<button
onClick={() => sendMessageToChat("What is the best move?")}
className="flex items-center gap-1 px-3 py-1 text-xs bg-green-100 text-green-800 rounded-full hover:bg-green-200 dark:bg-green-900 dark:text-green-200"
>
<Trophy size={12} /> {t.tutor.bestMove}
</button>
</div>
{/* Input Area */}
<form onSubmit={handleSubmit} className="p-4 border-t border-gray-200 dark:border-gray-700 flex gap-2">
<input
type="text"
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder={t.tutor.askCoach}
className="flex-1 p-2 border rounded-lg dark:bg-gray-700 dark:border-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500"
disabled={isLoading}
/>
<button
type="submit"
disabled={isLoading || !input.trim()}
className="p-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
<Send size={20} />
</button>
</form>
</div>
);
}