working app
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user