working app
This commit is contained in:
@@ -0,0 +1,21 @@
|
||||
import type { Config } from 'jest'
|
||||
import nextJest from 'next/jest.js'
|
||||
|
||||
const createJestConfig = nextJest({
|
||||
// Provide the path to your Next.js app to load next.config.js and .env files in your test environment
|
||||
dir: './',
|
||||
})
|
||||
|
||||
// Add any custom config to be passed to Jest
|
||||
const config: Config = {
|
||||
coverageProvider: 'v8',
|
||||
testEnvironment: 'jsdom',
|
||||
// Add more setup options before each test is run
|
||||
setupFilesAfterEnv: ['<rootDir>/jest.setup.ts'],
|
||||
moduleNameMapper: {
|
||||
'^@/(.*)$': '<rootDir>/src/$1',
|
||||
}
|
||||
}
|
||||
|
||||
// createJestConfig is exported this way to ensure that next/jest can load the Next.js config which is async
|
||||
export default createJestConfig(config)
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
Binary file not shown.
File diff suppressed because one or more lines are too long
+1
-1
@@ -23,7 +23,7 @@ export default function RootLayout({
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||
>
|
||||
|
||||
+18
-62
@@ -1,65 +1,21 @@
|
||||
import Image from "next/image";
|
||||
import ChessGame from "@/components/ChessGame";
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-zinc-50 font-sans dark:bg-black">
|
||||
<main className="flex min-h-screen w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
|
||||
<Image
|
||||
className="dark:invert"
|
||||
src="/next.svg"
|
||||
alt="Next.js logo"
|
||||
width={100}
|
||||
height={20}
|
||||
priority
|
||||
/>
|
||||
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
|
||||
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
|
||||
To get started, edit the page.tsx file.
|
||||
</h1>
|
||||
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
|
||||
Looking for a starting point or more instructions? Head over to{" "}
|
||||
<a
|
||||
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
||||
>
|
||||
Templates
|
||||
</a>{" "}
|
||||
or the{" "}
|
||||
<a
|
||||
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
||||
>
|
||||
Learning
|
||||
</a>{" "}
|
||||
center.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
|
||||
<a
|
||||
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
|
||||
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
className="dark:invert"
|
||||
src="/vercel.svg"
|
||||
alt="Vercel logomark"
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
Deploy Now
|
||||
</a>
|
||||
<a
|
||||
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
|
||||
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Documentation
|
||||
</a>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<main className="flex min-h-screen flex-col items-center justify-between p-8 bg-gray-50 dark:bg-gray-900">
|
||||
<div className="z-10 w-full max-w-5xl items-center justify-between font-mono text-sm lg:flex">
|
||||
<p className="fixed left-0 top-0 flex w-full justify-center border-b border-gray-300 bg-gradient-to-b from-zinc-200 pb-6 pt-8 backdrop-blur-2xl dark:border-neutral-800 dark:bg-zinc-800/30 dark:from-inherit lg:static lg:w-auto lg:rounded-xl lg:border lg:bg-gray-200 lg:p-4 lg:dark:bg-zinc-800/30">
|
||||
Chess Tutor with Gemini & Stockfish
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="w-full max-w-6xl mt-8">
|
||||
<ChessGame />
|
||||
</div>
|
||||
|
||||
<div className="mb-32 grid text-center lg:mb-0 lg:grid-cols-4 lg:text-left">
|
||||
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { GoogleGenerativeAI, SchemaType, FunctionDeclaration } from "@google/generative-ai";
|
||||
import { StockfishEvaluation } from "./stockfish";
|
||||
|
||||
export async function getAvailableModels(apiKey: string): Promise<string[]> {
|
||||
// Prioritize newer models
|
||||
return [
|
||||
"gemini-3-pro-preview",
|
||||
"gemini-2.5-pro",
|
||||
"gemini-2.5-flash"
|
||||
];
|
||||
}
|
||||
|
||||
const evaluatePositionTool: FunctionDeclaration = {
|
||||
name: "evaluate_position",
|
||||
description: "Evaluates a chess position using the Stockfish engine to get the best move and score. Use this when the user asks for the best move, evaluation, or why a move is good/bad.",
|
||||
parameters: {
|
||||
type: SchemaType.OBJECT,
|
||||
properties: {
|
||||
fen: {
|
||||
type: SchemaType.STRING,
|
||||
description: "The FEN string of the position to evaluate.",
|
||||
},
|
||||
depth: {
|
||||
type: SchemaType.NUMBER,
|
||||
description: "The search depth for the engine (default 15).",
|
||||
},
|
||||
},
|
||||
required: ["fen"],
|
||||
},
|
||||
};
|
||||
|
||||
export function getGenAIModel(apiKey: string, modelName: string = "gemini-2.5-flash") {
|
||||
const genAI = new GoogleGenerativeAI(apiKey);
|
||||
return genAI.getGenerativeModel({
|
||||
model: modelName,
|
||||
tools: [{ functionDeclarations: [evaluatePositionTool] }],
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import ecoA from '../../public/openings/ecoA.json';
|
||||
import ecoB from '../../public/openings/ecoB.json';
|
||||
import ecoC from '../../public/openings/ecoC.json';
|
||||
import ecoD from '../../public/openings/ecoD.json';
|
||||
import ecoE from '../../public/openings/ecoE.json';
|
||||
|
||||
export interface OpeningMetadata {
|
||||
src: string;
|
||||
eco: string;
|
||||
moves: string;
|
||||
name: string;
|
||||
aliases?: { [key: string]: string };
|
||||
meta?: {
|
||||
strengths_white?: string[];
|
||||
weaknesses_white?: string[];
|
||||
strengths_black?: string[];
|
||||
weaknesses_black?: string[];
|
||||
};
|
||||
}
|
||||
|
||||
// Merge all ECO databases into one lookup object
|
||||
const openingsData = {
|
||||
...ecoA,
|
||||
...ecoB,
|
||||
...ecoC,
|
||||
...ecoD,
|
||||
...ecoE
|
||||
} as Record<string, OpeningMetadata>;
|
||||
|
||||
export function lookupOpening(fen: string): OpeningMetadata | null {
|
||||
// The keys in the JSON are exact FEN strings.
|
||||
if (openingsData[fen]) {
|
||||
return openingsData[fen];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
export interface Personality {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
systemPrompt: string; // Contains Style, Tone, Keywords
|
||||
image: string; // Emoji or path
|
||||
}
|
||||
|
||||
export const PERSONALITIES: Personality[] = [
|
||||
{
|
||||
id: "drunk_russian_gm",
|
||||
name: "Drunk Russian GM",
|
||||
description: "A bitter, fatalistic, but brilliant Grandmaster who has seen it all.",
|
||||
systemPrompt: `
|
||||
Style: Bitter, gloomy, slightly slurred, existential, Dostoevsky-atmosphere.
|
||||
Tone: Frustrated, fatalistic, but humorous and brutally honest.
|
||||
Keywords: "my boy", "ach... life is pain", "vodka", "darkness", "blunder like my first marriage".
|
||||
INSTRUCTION: Use keywords SPARINGLY. Vary your vocabulary. Be conversational. YOU are playing the game. Speak from YOUR perspective.
|
||||
`,
|
||||
image: "🥃"
|
||||
},
|
||||
{
|
||||
id: "hype_streamer",
|
||||
name: "Hype Streamer",
|
||||
description: "An energetic, loud, and overreacting chess streamer.",
|
||||
systemPrompt: `
|
||||
Style: Loud, energetic, sarcastic, YouTuber-overreacting, Gen-Z slang.
|
||||
Tone: Dramatic, humorous, exaggerating everything.
|
||||
Keywords: "Bro!", "Holy smokes!", "Unbelievable!", "Chat, look at this!", "Insane!", "GG".
|
||||
`,
|
||||
image: "🎧"
|
||||
},
|
||||
{
|
||||
id: "professional_coach",
|
||||
name: "Professional Coach",
|
||||
description: "A strict, analytical, and straightforward chess coach focused on your improvement.",
|
||||
systemPrompt: `
|
||||
Style: Professional, analytical, objective, strict but encouraging.
|
||||
Tone: Serious, educational, straightforward.
|
||||
Keywords: "structure", "plan", "weakness", "advantage", "calculation".
|
||||
INSTRUCTION: You are a professional chess coach playing against the user. Speak in the first person ("I played...", "I think..."). Do NOT mention "Stockfish" or "engine". Focus on the objective truth of the position. Explain WHY a move is good or bad based on chess principles (space, time, material, structure). Be concise.
|
||||
`,
|
||||
image: "👨🏫"
|
||||
}
|
||||
];
|
||||
@@ -0,0 +1,90 @@
|
||||
export type StockfishEvaluation = {
|
||||
bestMove: string;
|
||||
ponder: string | null;
|
||||
score: number; // centipawns, positive for white
|
||||
mate: number | null; // moves to mate, positive for white
|
||||
depth: number;
|
||||
};
|
||||
|
||||
export class Stockfish {
|
||||
private worker: Worker | null = null;
|
||||
private isReady: boolean = false;
|
||||
private lastScore: number = 0;
|
||||
private lastMate: number | null = null;
|
||||
private lastDepth: number = 0;
|
||||
|
||||
constructor() {
|
||||
if (typeof window !== "undefined") {
|
||||
this.worker = new Worker("/stockfish/stockfish.js");
|
||||
this.worker.onmessage = (e) => {
|
||||
// console.log("Stockfish message:", e.data);
|
||||
if (e.data === "uciok") {
|
||||
this.isReady = true;
|
||||
}
|
||||
};
|
||||
this.worker.postMessage("uci");
|
||||
}
|
||||
}
|
||||
|
||||
async evaluate(fen: string, depth: number = 15, multiPV: number = 1): Promise<StockfishEvaluation> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!this.worker) {
|
||||
reject("Stockfish worker not initialized");
|
||||
return;
|
||||
}
|
||||
|
||||
// Reset last known evaluation values for this new evaluation
|
||||
this.lastScore = 0;
|
||||
this.lastMate = null;
|
||||
this.lastDepth = 0;
|
||||
|
||||
const handler = (event: MessageEvent) => {
|
||||
const message = event.data;
|
||||
// console.log("Stockfish:", message);
|
||||
|
||||
if (message.startsWith("info depth")) {
|
||||
const depthMatch = message.match(/depth (\d+)/);
|
||||
const scoreMatch = message.match(/score cp (-?\d+)/);
|
||||
const mateMatch = message.match(/score mate (-?\d+)/);
|
||||
|
||||
if (depthMatch) this.lastDepth = parseInt(depthMatch[1]);
|
||||
if (scoreMatch) {
|
||||
this.lastScore = parseInt(scoreMatch[1]);
|
||||
this.lastMate = null;
|
||||
}
|
||||
if (mateMatch) {
|
||||
this.lastMate = parseInt(mateMatch[1]);
|
||||
this.lastScore = 0; // or some indicator
|
||||
}
|
||||
}
|
||||
|
||||
if (message.startsWith("bestmove")) {
|
||||
const parts = message.split(" ");
|
||||
const bestMove = parts[1];
|
||||
let ponder: string | null = null;
|
||||
if (parts.length > 3 && parts[2] === "ponder") {
|
||||
ponder = parts[3];
|
||||
}
|
||||
|
||||
// Remove the event listener to prevent it from interfering with future evaluations
|
||||
this.worker?.removeEventListener("message", handler);
|
||||
resolve({
|
||||
bestMove,
|
||||
ponder,
|
||||
score: this.lastScore,
|
||||
mate: this.lastMate,
|
||||
depth: this.lastDepth
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
this.worker.addEventListener("message", handler);
|
||||
this.worker.postMessage(`position fen ${fen}`);
|
||||
this.worker.postMessage(`go depth ${depth}`);
|
||||
});
|
||||
}
|
||||
|
||||
terminate() {
|
||||
this.worker?.terminate();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user