working app

This commit is contained in:
Stefan
2025-11-23 10:51:00 +01:00
parent f6480a43c6
commit 30f1e9e3a5
22 changed files with 106271 additions and 63 deletions
+84
View File
@@ -0,0 +1,84 @@
"use client";
import { useState, useEffect } from "react";
import { Key } from "lucide-react";
interface APIKeyInputProps {
onKeySubmit: (key: string) => void;
}
export function APIKeyInput({ onKeySubmit }: APIKeyInputProps) {
const [key, setKey] = useState("");
const [isOpen, setIsOpen] = useState(false);
useEffect(() => {
const envKey = process.env.NEXT_PUBLIC_GEMINI_API_KEY;
const storedKey = localStorage.getItem("gemini_api_key");
if (envKey) {
onKeySubmit(envKey);
} else if (storedKey) {
onKeySubmit(storedKey);
} else {
setIsOpen(true);
}
}, [onKeySubmit]);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (key.trim()) {
localStorage.setItem("gemini_api_key", key.trim());
onKeySubmit(key.trim());
setIsOpen(false);
}
};
if (!isOpen) {
return (
<button
onClick={() => setIsOpen(true)}
className="fixed bottom-4 right-4 p-2 bg-gray-200 dark:bg-gray-800 rounded-full hover:bg-gray-300 dark:hover:bg-gray-700 transition-colors"
title="Update API Key"
>
<Key size={20} />
</button>
);
}
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white dark:bg-gray-800 p-6 rounded-lg shadow-xl max-w-md w-full mx-4">
<h2 className="text-xl font-bold mb-4">Enter Gemini API Key</h2>
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
To receive AI feedback, please enter your Google Gemini API key.
It will be stored locally in your browser.
</p>
<form onSubmit={handleSubmit} className="space-y-4">
<input
type="password"
value={key}
onChange={(e) => setKey(e.target.value)}
placeholder="AIzaSy..."
className="w-full p-2 border rounded dark:bg-gray-700 dark:border-gray-600"
required
/>
<div className="flex justify-end gap-2">
<button
type="button"
onClick={() => setIsOpen(false)}
className="px-4 py-2 text-gray-600 hover:text-gray-800 dark:text-gray-400 dark:hover:text-gray-200"
>
Cancel
</button>
<button
type="submit"
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
>
Save Key
</button>
</div>
</form>
</div>
</div>
);
}
+107
View File
@@ -0,0 +1,107 @@
import { render, screen, fireEvent, waitFor, act } from "@testing-library/react";
import ChessGame from "./ChessGame";
import { Chess } from "chess.js";
// Mock dependencies
jest.mock("react-chessboard", () => ({
Chessboard: ({ options }: any) => (
<div data-testid="chessboard" onClick={() => {
// Simulate a move drop
if (options.onPieceDrop) {
options.onPieceDrop({ sourceSquare: "e2", targetSquare: "e4" });
}
}}>
Chessboard Mock
</div>
),
}));
jest.mock("../lib/stockfish", () => {
return {
Stockfish: jest.fn().mockImplementation(() => ({
evaluate: jest.fn().mockResolvedValue({
score: 0.5,
mate: null,
bestMove: "e7e5",
depth: 15
}),
terminate: jest.fn(),
})),
};
});
jest.mock("./Tutor", () => ({
Tutor: ({ currentFen, userMove, computerMove, evalP0, evalP2, openingData, language }: any) => (
<div data-testid="tutor">
Tutor Mock (Fen: {currentFen})
{userMove && <span>User Move: {userMove.san}</span>}
{computerMove && <span>Computer Move: {computerMove.san}</span>}
{evalP0 && <span>Eval P0: {evalP0.score}</span>}
{evalP2 && <span>Eval P2: {evalP2.score}</span>}
{openingData && <span>Opening: {openingData.name}</span>}
<span>Language: {language}</span>
</div>
),
}));
// Mock APIKeyInput to avoid portal issues or complex interactions if needed,
// but since we integrated it into the start screen, we can test the interaction directly.
jest.mock("./APIKeyInput", () => ({
APIKeyInput: ({ onKeySubmit }: any) => (
<button onClick={() => onKeySubmit("test-key")} data-testid="api-key-trigger">
Set API Key
</button>
),
}));
describe("ChessGame Component", () => {
beforeEach(() => {
localStorage.clear();
jest.clearAllMocks();
});
it("renders the start screen initially", () => {
render(<ChessGame />);
expect(screen.getByText("Chess Tutor AI")).toBeInTheDocument();
expect(screen.getByText("1. Settings")).toBeInTheDocument();
expect(screen.getByText("2. Start Game")).toBeInTheDocument();
});
it("starts the game after entering API key and selecting a personality", async () => {
render(<ChessGame />);
// 1. Enter API Key
const keyInput = screen.getByPlaceholderText("AIzaSy...");
fireEvent.change(keyInput, { target: { value: "test-api-key" } });
// 2. Select Personality (now enabled)
fireEvent.click(screen.getByText("Drunk Russian GM"));
await waitFor(() => {
expect(screen.getByTestId("chessboard")).toBeInTheDocument();
expect(screen.getByTestId("tutor")).toBeInTheDocument();
});
});
it("handles user move and triggers analysis", async () => {
render(<ChessGame />);
// 1. Enter API Key
const keyInput = screen.getByPlaceholderText("AIzaSy...");
fireEvent.change(keyInput, { target: { value: "test-api-key" } });
// 2. Select Personality
fireEvent.click(screen.getByText("Drunk Russian GM"));
await waitFor(() => screen.getByTestId("chessboard"));
// Make a move
fireEvent.click(screen.getByTestId("chessboard"));
// Wait for analysis and computer move
await waitFor(() => {
expect(screen.getByText(/User Move:/)).toBeInTheDocument();
expect(screen.getByText(/Eval P2:/)).toBeInTheDocument();
}, { timeout: 10000 });
}, 15000);
});
+491
View File
@@ -0,0 +1,491 @@
"use client";
import { useState, useEffect, useCallback, useRef } from "react";
import { Chess, Move } from "chess.js";
import { Chessboard } from "react-chessboard";
import { Stockfish, StockfishEvaluation } from "@/lib/stockfish";
import { Tutor } from "./Tutor";
import { APIKeyInput } from "./APIKeyInput";
import { EvaluationBar } from "./EvaluationBar";
import { Personality, PERSONALITIES } from "@/lib/personalities";
import { lookupOpening, OpeningMetadata } from "@/lib/openings";
import { GameAnalysisModal } from "./GameAnalysisModal";
import { Brain } from "lucide-react";
export default function ChessGame() {
const gameRef = useRef(new Chess());
const [fen, setFen] = useState(gameRef.current.fen());
const [stockfish, setStockfish] = useState<Stockfish | null>(null);
// Analysis States
// evalP0: Evaluation of position BEFORE user move
const [evalP0, setEvalP0] = useState<StockfishEvaluation | null>(null);
// evalP2: Evaluation of position AFTER bot move
const [evalP2, setEvalP2] = useState<StockfishEvaluation | null>(null);
// Opening Data
const [openingData, setOpeningData] = useState<OpeningMetadata | null>(null);
const [userMove, setUserMove] = useState<Move | null>(null);
const [computerMove, setComputerMove] = useState<Move | null>(null);
const [isAnalyzing, setIsAnalyzing] = useState(false);
const [apiKey, setApiKey] = useState<string | null>(null);
const [stockfishDepth, setStockfishDepth] = useState(15);
// New States
const [language, setLanguage] = useState<'en' | 'de' | 'fr' | 'it'>('en');
const [gameStarted, setGameStarted] = useState(false);
const [showNewGameOptions, setShowNewGameOptions] = useState(false);
const [showAnalysisModal, setShowAnalysisModal] = useState(false);
const [customFen, setCustomFen] = useState("");
// Personality State
const [selectedPersonality, setSelectedPersonality] = useState<Personality | null>(null);
useEffect(() => {
const sf = new Stockfish();
setStockfish(sf);
return () => sf.terminate();
}, []);
// Load Game State on Mount
useEffect(() => {
const savedGame = localStorage.getItem("chess_tutor_save");
if (savedGame) {
try {
const data = JSON.parse(savedGame);
if (data.fen) {
// Don't set gameRef here yet, wait for user action
// But we can preload state to show "Resume" option
setFen(data.fen);
}
if (data.language) setLanguage(data.language);
if (data.selectedPersonality) setSelectedPersonality(data.selectedPersonality);
if (data.apiKey) setApiKey(data.apiKey);
} catch (e) {
console.error("Failed to load game:", e);
}
}
}, []);
// Save Game State on Change
useEffect(() => {
if (!gameStarted) return;
const saveData = {
fen,
language,
selectedPersonality,
apiKey
};
localStorage.setItem("chess_tutor_save", JSON.stringify(saveData));
}, [fen, language, selectedPersonality, apiKey, gameStarted]);
// Pre-Analysis (P0): Run whenever it's White's turn (User) and we are waiting for a move
useEffect(() => {
if (stockfish && gameRef.current.turn() === 'w' && !isAnalyzing) {
stockfish.evaluate(gameRef.current.fen(), stockfishDepth).then(evalResult => {
setEvalP0(evalResult);
}).catch(err => console.error("Pre-analysis failed:", err));
}
}, [fen, stockfish, stockfishDepth, isAnalyzing]);
const makeAMove = useCallback(
(move: { from: string; to: string; promotion?: string }) => {
try {
const game = gameRef.current;
const result = game.move(move);
if (result) {
const newFen = game.fen();
setFen(newFen);
return { result, newFen };
}
} catch (e) {
return null;
}
return null;
},
[]
);
function onDrop({ sourceSquare, targetSquare }: { sourceSquare: string; targetSquare: string | null }) {
if (!targetSquare || !stockfish) return false;
const move = {
from: sourceSquare,
to: targetSquare,
promotion: "q", // always promote to queen for simplicity
};
// 1. User Move (P0 -> P1)
const moveResult = makeAMove(move);
if (!moveResult) return false;
setUserMove(moveResult.result);
// Reset Computer State immediately to prevent "Hallucination" / Double Chat
setComputerMove(null);
setEvalP2(null);
setOpeningData(null);
setIsAnalyzing(true);
const { newFen: fenP1 } = moveResult;
// 2. Bot Move (P1 -> P2)
// We need to find the best move for Black from P1
stockfish.evaluate(fenP1, stockfishDepth).then(p1Eval => {
// We don't store p1Eval for the Tutor, but we use it to decide the move
setTimeout(() => {
const computerMoveData = {
from: p1Eval.bestMove.substring(0, 2),
to: p1Eval.bestMove.substring(2, 4),
promotion: p1Eval.bestMove.length > 4 ? p1Eval.bestMove.substring(4, 5) : "q"
};
const compResult = makeAMove(computerMoveData);
if (compResult) {
setComputerMove(compResult.result);
const { newFen: fenP2 } = compResult;
// 3. Post-Eval (P2)
stockfish.evaluate(fenP2, stockfishDepth).then(p2Eval => {
setEvalP2(p2Eval);
// 4. Opening Lookup
const opening = lookupOpening(fenP2);
setOpeningData(opening);
setIsAnalyzing(false);
}).catch(err => {
console.error("P2 analysis failed:", err);
setIsAnalyzing(false);
});
} else {
setIsAnalyzing(false);
}
}, 500);
}).catch(err => {
console.error("Bot move analysis failed:", err);
setIsAnalyzing(false);
});
return true;
}
const handleResume = () => {
// gameRef needs to be synced with state fen
gameRef.current = new Chess(fen);
setGameStarted(true);
};
const handleNewGame = (personality: Personality) => {
const startFen = customFen.trim() || "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1";
try {
const newGame = new Chess(startFen);
gameRef.current = newGame;
setFen(startFen);
setSelectedPersonality(personality);
// Reset Analysis State
setUserMove(null);
setComputerMove(null);
setEvalP0(null);
setEvalP2(null);
setOpeningData(null);
setGameStarted(true);
setCustomFen(""); // Clear input
} catch (e) {
alert("Invalid FEN string");
}
};
const handleBackToMenu = () => {
setGameStarted(false);
setShowNewGameOptions(false);
};
if (!gameStarted) {
const hasSavedGame = fen !== "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1";
return (
<div className="flex flex-col items-center justify-center min-h-screen bg-gray-100 dark:bg-gray-900 p-4">
<h1 className="text-4xl font-bold mb-8 text-gray-800 dark:text-white">Chess Tutor AI</h1>
<div className="bg-white dark:bg-gray-800 p-8 rounded-xl shadow-lg max-w-2xl w-full space-y-8">
{/* Step 1: Language & API Key */}
<div className="space-y-4">
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">1. Settings</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Language</label>
<div className="flex gap-2">
{['en', 'de', 'fr', 'it'].map((lang) => (
<button
key={lang}
onClick={() => setLanguage(lang as any)}
className={`px-3 py-2 rounded-lg border text-sm ${language === lang ? 'bg-blue-600 text-white border-blue-600' : 'bg-gray-50 dark:bg-gray-700 border-gray-200 dark:border-gray-600'}`}
>
{lang.toUpperCase()}
</button>
))}
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Google Gemini API Key</label>
<input
type="password"
placeholder="AIzaSy..."
value={apiKey || ""}
onChange={(e) => setApiKey(e.target.value)}
className="w-full p-2 border rounded dark:bg-gray-700 dark:border-gray-600"
/>
<p className="text-xs text-gray-500 mt-1">
<a href="https://aistudio.google.com/app/apikey" target="_blank" rel="noreferrer" className="text-blue-600 hover:underline">Get free key</a>
</p>
</div>
</div>
</div>
{/* Step 2: Game Actions */}
<div className="space-y-4">
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">2. Start Game</h2>
{!apiKey ? (
<div className="p-4 bg-yellow-50 text-yellow-800 rounded-lg text-sm">
Please enter a valid API Key to continue.
</div>
) : (
<div className="space-y-4">
{/* Resume Option */}
{hasSavedGame && !showNewGameOptions && (
<div className="space-y-3">
<button
onClick={handleResume}
className="w-full py-4 bg-green-600 text-white rounded-xl hover:bg-green-700 font-bold text-lg shadow-md transition-transform transform hover:scale-[1.02]"
>
Resume Previous Game
</button>
<button
onClick={() => setShowNewGameOptions(true)}
className="w-full py-2 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white text-sm"
>
Start New Game instead...
</button>
</div>
)}
{/* New Game Options */}
{(!hasSavedGame || showNewGameOptions) && (
<div className="space-y-6 animate-in fade-in slide-in-from-top-4 duration-300">
{/* FEN Import */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Import Position (Optional FEN)
</label>
<input
type="text"
placeholder="rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1"
value={customFen}
onChange={(e) => setCustomFen(e.target.value)}
className="w-full p-2 border rounded dark:bg-gray-700 dark:border-gray-600 font-mono text-sm"
/>
</div>
{/* Personality Grid */}
<div>
<p className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Choose Your Coach:</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{PERSONALITIES.map(p => (
<button
key={p.id}
onClick={() => handleNewGame(p)}
className="bg-gray-50 dark:bg-gray-700 p-4 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-600 transition-colors border border-gray-200 dark:border-gray-600 flex flex-col items-center text-center"
>
<div className="text-4xl mb-2">{p.image}</div>
<h3 className="font-bold text-gray-900 dark:text-white">{p.name}</h3>
<p className="text-xs text-gray-500 dark:text-gray-400">{p.description}</p>
</button>
))}
</div>
</div>
{hasSavedGame && (
<button
onClick={() => setShowNewGameOptions(false)}
className="text-sm text-gray-500 hover:text-gray-700"
>
Cancel
</button>
)}
</div>
)}
</div>
)}
</div>
</div>
</div>
);
}
return (
<div className="flex flex-col md:flex-row gap-8 w-full max-w-6xl mx-auto p-4">
{/* API Key Input is now handled in start screen, but we keep the button for updates */}
{/* <APIKeyInput onKeySubmit={setApiKey} /> */}
<div className="w-full md:w-2/3 flex flex-col gap-4">
{/* Header with Back Button */}
<div className="flex justify-between items-center">
<button
onClick={handleBackToMenu}
className="px-4 py-2 bg-gray-200 dark:bg-gray-700 rounded-lg hover:bg-gray-300 dark:hover:bg-gray-600 text-sm font-medium transition-colors"
>
Back to Menu
</button>
<div className="text-sm text-gray-500">
Playing as White vs {selectedPersonality?.name}
</div>
</div>
<div className="bg-white dark:bg-gray-800 p-4 rounded-lg shadow-lg flex gap-4">
<div className="h-[560px]"> {/* Match board height roughly */}
<EvaluationBar
score={isAnalyzing ? null : evalP0?.score} // Show P0 score while waiting, or maybe P2 after move? Let's show current board eval.
mate={isAnalyzing ? null : evalP0?.mate}
/>
</div>
<div className="flex-1">
<Chessboard
options={{
position: fen,
onPieceDrop: ({ sourceSquare, targetSquare }) => onDrop({ sourceSquare, targetSquare }),
darkSquareStyle: { backgroundColor: '#779954' },
lightSquareStyle: { backgroundColor: '#e9edcc' },
animationDurationInMs: 200
}}
/>
</div>
</div>
<div className="bg-white dark:bg-gray-800 p-4 rounded-lg shadow-lg">
<div className="flex items-center justify-between mb-2">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
Stockfish Strength (Depth: {stockfishDepth})
</label>
<button
onClick={() => {
const game = gameRef.current;
// Undo twice: once for computer, once for user
game.undo();
game.undo();
setFen(game.fen());
// Reset moves to prevent re-analysis of old moves
setUserMove(null);
setComputerMove(null);
setEvalP0(null);
setEvalP2(null);
setOpeningData(null);
}}
className="px-3 py-1 text-sm bg-red-100 text-red-700 rounded hover:bg-red-200 dark:bg-red-900 dark:text-red-200 transition-colors"
>
Undo Last Move
</button>
</div>
<input
type="range"
min="1"
max="20"
value={stockfishDepth}
onChange={(e) => setStockfishDepth(parseInt(e.target.value))}
className="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer dark:bg-gray-700"
/>
</div>
{/* PGN Display */}
{/* Game History (Scrollable List) */}
<div className="bg-white dark:bg-gray-800 p-4 rounded-lg shadow-lg flex-1 min-h-0 flex flex-col">
<div className="flex items-center justify-between mb-2">
<h3 className="text-sm font-medium text-gray-700 dark:text-gray-300">Game History</h3>
<button
onClick={() => setShowAnalysisModal(true)}
className="text-xs bg-purple-100 text-purple-700 px-2 py-1 rounded hover:bg-purple-200 dark:bg-purple-900 dark:text-purple-200 flex items-center gap-1"
>
<Brain size={12} /> Analyze
</button>
</div>
<div className="flex-1 overflow-y-auto border border-gray-200 dark:border-gray-700 rounded bg-gray-50 dark:bg-gray-900 p-2">
<table className="w-full text-sm text-left">
<thead>
<tr className="text-gray-500 dark:text-gray-400 border-b border-gray-200 dark:border-gray-700">
<th className="py-1 px-2 w-12">#</th>
<th className="py-1 px-2">White</th>
<th className="py-1 px-2">Black</th>
</tr>
</thead>
<tbody>
{(() => {
const history = gameRef.current.history();
const rows = [];
for (let i = 0; i < history.length; i += 2) {
rows.push(
<tr key={i} className="border-b border-gray-100 dark:border-gray-800 last:border-0">
<td className="py-1 px-2 text-gray-500 dark:text-gray-500">{Math.floor(i / 2) + 1}.</td>
<td className="py-1 px-2 font-medium text-gray-900 dark:text-gray-200">{history[i]}</td>
<td className="py-1 px-2 font-medium text-gray-900 dark:text-gray-200">{history[i + 1] || ""}</td>
</tr>
);
}
if (rows.length === 0) {
return (
<tr>
<td colSpan={3} className="py-4 text-center text-gray-500 italic">
No moves yet.
</td>
</tr>
);
}
return rows;
})()}
</tbody>
</table>
{/* Auto-scroll anchor */}
<div ref={(el) => el?.scrollIntoView({ behavior: "smooth" })} />
</div>
</div>
</div>
<div className="w-full md:w-1/3">
<Tutor
currentFen={fen}
userMove={userMove}
computerMove={computerMove}
stockfish={stockfish}
evalP0={evalP0}
evalP2={evalP2}
openingData={openingData}
onAnalysisComplete={() => { }}
apiKey={apiKey}
personality={selectedPersonality!}
language={language}
/>
</div>
{/* Analysis Modal */}
{showAnalysisModal && (
<GameAnalysisModal
fen={fen}
stockfish={stockfish}
apiKey={apiKey}
language={language}
onClose={() => setShowAnalysisModal(false)}
/>
)}
</div>
);
}
+30
View File
@@ -0,0 +1,30 @@
import { render, screen } from "@testing-library/react";
import { EvaluationBar } from "./EvaluationBar";
import "@testing-library/jest-dom";
describe("EvaluationBar", () => {
it("renders 0.0 for initial state", () => {
render(<EvaluationBar score={0} />);
expect(screen.getByText("0.0")).toBeInTheDocument();
});
it("renders positive score for white advantage", () => {
render(<EvaluationBar score={150} />);
expect(screen.getByText("+1.5")).toBeInTheDocument();
});
it("renders negative score for black advantage", () => {
render(<EvaluationBar score={-230} />);
expect(screen.getByText("-2.3")).toBeInTheDocument();
});
it("renders mate score", () => {
render(<EvaluationBar mate={3} />);
expect(screen.getByText("M3")).toBeInTheDocument();
});
it("renders negative mate score", () => {
render(<EvaluationBar mate={-5} />);
expect(screen.getByText("M5")).toBeInTheDocument();
});
});
+67
View File
@@ -0,0 +1,67 @@
"use client";
import clsx from "clsx";
interface EvaluationBarProps {
score?: number | null; // centipawns
mate?: number | null; // moves to mate
}
export function EvaluationBar({ score, mate }: EvaluationBarProps) {
// Calculate white's percentage height
// Using sigmoid-like function for score: P = 1 / (1 + 10^(-score/400))
// This is a standard way to visualize CP advantage.
let whiteHeightPercent = 50;
let label = "0.0";
if (mate !== null && mate !== undefined) {
// Mate detected
if (mate > 0) {
whiteHeightPercent = 100;
label = `M${mate}`;
} else {
whiteHeightPercent = 0;
label = `M${Math.abs(mate)}`;
}
} else if (score !== null && score !== undefined) {
// Score is in centipawns. 100 cp = 1 pawn.
// We clamp the visual score somewhat to avoid extreme compression
const winChance = 1 / (1 + Math.pow(10, -score / 400));
whiteHeightPercent = winChance * 100;
// Format label: +1.5 or -0.3
const pawnScore = score / 100;
label = pawnScore > 0 ? `+${pawnScore.toFixed(1)}` : pawnScore.toFixed(1);
if (score === 0) label = "0.0";
}
// Invert label color based on background
// If whiteHeightPercent is high, top is white, text should be black if it's at the top?
// Actually, usually the text is placed based on who is winning or fixed.
// Let's place text at top for White advantage and bottom for Black?
// Or just center it? Standard is usually top/bottom or floating.
// Let's keep it simple: Text always visible, color contrasting with the bar it's on.
// We'll put the text in a small badge that floats?
// Or just inside the bar.
return (
<div className="w-8 h-full bg-gray-800 border border-gray-400 flex flex-col-reverse relative overflow-hidden rounded shadow-inner">
{/* Black background is the container (h-full) */}
{/* White bar grows from bottom (flex-col-reverse) */}
<div
className="w-full bg-white transition-all duration-500 ease-in-out"
style={{ height: `${whiteHeightPercent}%` }}
/>
{/* Score Label */}
<div className={clsx(
"absolute w-full text-center text-xs font-bold py-1 select-none",
whiteHeightPercent > 50 ? "top-0 text-gray-800" : "bottom-0 text-white"
)}>
{label}
</div>
</div>
);
}
+145
View File
@@ -0,0 +1,145 @@
"use client";
import { useState, useEffect } from "react";
import { Stockfish, StockfishEvaluation } from "@/lib/stockfish";
import { OpeningMetadata, lookupOpening } from "@/lib/openings";
import { getGenAIModel } from "@/lib/gemini";
import { Loader2, X, Brain, Trophy, AlertTriangle } from "lucide-react";
interface GameAnalysisModalProps {
fen: string;
stockfish: Stockfish | null;
apiKey: string | null;
language: 'en' | 'de' | 'fr' | 'it';
onClose: () => void;
}
export function GameAnalysisModal({ fen, stockfish, apiKey, language, onClose }: GameAnalysisModalProps) {
const [evaluation, setEvaluation] = useState<StockfishEvaluation | null>(null);
const [opening, setOpening] = useState<OpeningMetadata | null>(null);
const [summary, setSummary] = useState<string>("");
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
const analyze = async () => {
setIsLoading(true);
try {
// 1. Stockfish Evaluation
let evalResult: StockfishEvaluation | null = null;
if (stockfish) {
evalResult = await stockfish.evaluate(fen, 15); // Quick depth
setEvaluation(evalResult);
}
// 2. Opening Lookup
const openingData = lookupOpening(fen);
setOpening(openingData);
// 3. LLM Summary
if (apiKey && evalResult) {
const model = getGenAIModel(apiKey, "gemini-2.5-flash");
const prompt = `
You are a Chess Grandmaster Analyst.
Analyze this position for the user.
DATA:
- FEN: ${fen}
- Evaluation: ${evalResult.score} cp (positive = White advantage, negative = Black advantage)
- Mate in: ${evalResult.mate ?? "N/A"}
- Best Move: ${evalResult.bestMove}
- Opening: ${openingData ? `${openingData.name} (${openingData.eco})` : "Unknown/Midgame"}
INSTRUCTIONS:
1. Summarize who is winning and why (based on score).
2. Identify the key strategic factors (space, piece activity, king safety).
3. Mention the opening if relevant.
4. Keep it concise (max 3-4 sentences).
5. Respond in ${language.toUpperCase()}.
OUTPUT FORMAT:
Plain text paragraph.
`;
const result = await model.generateContent(prompt);
setSummary(result.response.text());
} else if (!apiKey) {
setSummary("Please provide an API Key to get an AI summary.");
}
} catch (e) {
console.error("Analysis failed:", e);
setSummary("Failed to generate analysis.");
} finally {
setIsLoading(false);
}
};
analyze();
}, [fen, stockfish, apiKey, language]);
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4">
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-2xl max-w-lg w-full overflow-hidden border border-gray-200 dark:border-gray-700 animate-in fade-in zoom-in duration-200">
{/* Header */}
<div className="p-4 border-b border-gray-200 dark:border-gray-700 flex justify-between items-center bg-gray-50 dark:bg-gray-900">
<h2 className="text-lg font-bold flex items-center gap-2 text-gray-900 dark:text-white">
<Brain className="text-purple-600" />
Game Analysis
</h2>
<button onClick={onClose} className="text-gray-500 hover:text-gray-700 dark:hover:text-gray-300">
<X size={20} />
</button>
</div>
{/* Content */}
<div className="p-6 space-y-6">
{isLoading ? (
<div className="flex flex-col items-center justify-center py-8 space-y-4">
<Loader2 className="animate-spin text-purple-600" size={48} />
<p className="text-gray-500">Analyzing position...</p>
</div>
) : (
<>
{/* Evaluation Score */}
<div className="flex items-center justify-between p-4 bg-gray-100 dark:bg-gray-700 rounded-lg">
<div>
<p className="text-sm text-gray-500 dark:text-gray-400">Evaluation</p>
<p className={`text-2xl font-bold ${(evaluation?.score || 0) > 0 ? "text-green-600" : (evaluation?.score || 0) < 0 ? "text-red-600" : "text-gray-600"
}`}>
{evaluation?.mate
? `Mate in ${evaluation.mate}`
: `${(evaluation?.score || 0) > 0 ? "+" : ""}${(evaluation?.score || 0) / 100}`}
</p>
</div>
<div className="text-right">
<p className="text-sm text-gray-500 dark:text-gray-400">Best Move</p>
<p className="text-xl font-mono font-bold text-gray-900 dark:text-white">
{evaluation?.bestMove}
</p>
</div>
</div>
{/* Opening Info */}
{opening && (
<div className="p-4 border border-blue-200 bg-blue-50 dark:bg-blue-900/20 dark:border-blue-800 rounded-lg">
<h3 className="font-semibold text-blue-800 dark:text-blue-300 mb-1">Opening Identified</h3>
<p className="text-blue-900 dark:text-blue-100">{opening.name} ({opening.eco})</p>
</div>
)}
{/* AI Summary */}
<div>
<h3 className="font-semibold text-gray-900 dark:text-white mb-2 flex items-center gap-2">
<Trophy size={16} className="text-yellow-500" />
Coach's Summary
</h3>
<div className="p-4 bg-purple-50 dark:bg-purple-900/20 rounded-lg text-gray-800 dark:text-gray-200 leading-relaxed">
{summary}
</div>
</div>
</>
)}
</div>
</div>
</div>
);
}
+309
View File
@@ -0,0 +1,309 @@
"use client";
import { useState, useEffect, useRef } from "react";
import { Stockfish, StockfishEvaluation } from "@/lib/stockfish";
import { 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";
interface TutorProps {
currentFen: string;
userMove: Move | null;
computerMove: Move | null;
stockfish: Stockfish | null;
evalP0: StockfishEvaluation | null;
evalP2: StockfishEvaluation | null;
openingData: OpeningMetadata | null;
onAnalysisComplete: () => void;
apiKey: string | null;
personality: Personality;
language: 'en' | 'de' | 'fr' | 'it';
}
interface Message {
role: "user" | "model";
text: string;
timestamp: number;
}
export function Tutor({ currentFen, userMove, computerMove, stockfish, evalP0, evalP2, openingData, onAnalysisComplete, apiKey, personality, language }: TutorProps) {
const [messages, setMessages] = useState<Message[]>([
{ role: "model", text: "Hello! I am " + personality.name + ". " + personality.description + " Let's play!", timestamp: Date.now() }
]);
const [input, setInput] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [chatSession, setChatSession] = useState<ChatSession | null>(null);
const messagesEndRef = useRef<HTMLDivElement>(null);
// 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.
You must strictly follow the personality defined below.
Do NOT invent moves or evaluations. Use the provided JSON data.
PERSONALITY:
${personality.systemPrompt}
INSTRUCTIONS:
- You are the opponent (Black). You are playing against the User (White).
- You are NOT an AI assistant analyzing a game. You ARE the player.
- 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").
- NEVER mention "Stockfish", "engine", "computer", "machine", or "AI".
- If the evaluation says you are winning, be confident/arrogant (depending on personality).
- If you are losing, be frustrated/worried (depending on personality).
- 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 the player (Black). I will speak in ${language} and never mention the engine.` }]
}
],
});
setChatSession(session);
// Reset messages on language/personality change
setMessages([{ role: "model", text: `Hello! I am ${personality.name}. Let's play!`, timestamp: Date.now() }]);
}
}, [apiKey, personality, language]);
// Scroll to bottom
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, [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) {
openingInstruction = `
OPENING IDENTIFIED: ${openingData.name} (${openingData.eco}).
You MUST mention the opening name.
You can use this metadata to explain the position:
- Strengths (White): ${openingData.meta?.strengths_white?.join(", ")}
- Weaknesses (White): ${openingData.meta?.weaknesses_white?.join(", ")}
- Strengths (Black): ${openingData.meta?.strengths_black?.join(", ")}
- Weaknesses (Black): ${openingData.meta?.weaknesses_black?.join(", ")}
`;
} else {
// openingInstruction = "NO opening identified. Do NOT invent an opening name. Do NOT mention openings.";
// Relaxed instruction to allow general commentary if no specific opening is found, but still forbid inventing names.
openingInstruction = "NO specific opening identified from database. Do NOT invent an opening name. Focus on the position.";
}
const prompt = `
[SYSTEM TRIGGER: move_exchange]
User (White) Move: ${userMove.san}
My (Black) Reply: ${computerMove.san}
My Internal Thoughts (Data):
- Pre-Eval (Before User Move): ${preScore} cp
- Post-Eval (After My Reply): ${postScore} cp
- Delta: ${delta} cp
INSTRUCTIONS:
1. ${evalInstruction}
2. ${openingInstruction}
3. 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, language]);
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();
if (lower.includes("best move") || lower.includes("solution") || lower.includes("tell me")) {
finalPrompt = `[SYSTEM TRIGGER: exact_move]\nUser Question: ${text}\nData: Post-Eval Best Move: ${evalP2?.bestMove}`;
} else if (lower.includes("hint") || lower.includes("tip") || lower.includes("help")) {
finalPrompt = `[SYSTEM TRIGGER: hint]\nUser Question: ${text}`;
}
}
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("");
};
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-[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 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 whitespace-pre-wrap",
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"
)}>
{msg.text}
</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 ref={messagesEndRef} />
</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} /> 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} /> Best Move
</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="Ask your coach..."
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>
);
}