diff --git a/src/components/geminiExplanationDialog.tsx b/src/components/geminiExplanationDialog.tsx new file mode 100644 index 0000000..6cc7a24 --- /dev/null +++ b/src/components/geminiExplanationDialog.tsx @@ -0,0 +1,552 @@ +import { useState, useEffect, useCallback } from "react"; +import { + Dialog, + DialogTitle, + DialogContent, + DialogActions, + Button, + IconButton, + Box, + Typography, + CircularProgress, + Fab, + Tooltip, + Paper, + TextField, + Switch, + FormControlLabel, + Tabs, + Tab, +} from "@mui/material"; +import { Icon } from "@iconify/react"; +import { useAtom, useAtomValue } from "jotai"; +import { + currentPositionAtom, + geminiSettingsAtom, +} from "@/sections/analysis/states"; +// eslint-disable-next-line import/no-named-as-default +import useGeminiExplanation from "@/hooks/useGeminiExplanation"; +import { GeminiMoveAnalysisParams } from "@/lib/gemini"; +import { moveLineUciToSan } from "@/lib/chess"; +import { MoveClassification } from "@/types/enums"; + +export default function GeminiExplanationDialog() { + const [open, setOpen] = useState(false); + const [explanation, setExplanation] = useState(""); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + const currentPosition = useAtomValue(currentPositionAtom); + const [geminiSettings, setGeminiSettings] = useAtom(geminiSettingsAtom); + const { getExplanation } = useGeminiExplanation(); + + const [apiKey, setApiKey] = useState(""); + const [tabValue, setTabValue] = useState(0); + + useEffect(() => { + setApiKey(geminiSettings.apiKey || ""); + }, [geminiSettings.apiKey]); + + const handleOpen = () => { + setOpen(true); + setTabValue(0); + fetchExplanation(); + }; + + const handleClose = () => { + setOpen(false); + }; + + const handleChangeTab = (_event: React.SyntheticEvent, newValue: number) => { + setTabValue(newValue); + }; + + const handleToggleEnabled = () => { + setGeminiSettings({ + ...geminiSettings, + enabled: !geminiSettings.enabled, + }); + }; + + const handleSaveApiKey = () => { + setGeminiSettings({ + ...geminiSettings, + apiKey: apiKey.trim(), + }); + }; + + const fetchExplanation = useCallback(async () => { + if ( + !currentPosition.lastMove || + !currentPosition.eval?.moveClassification + ) { + setError("No move to explain. Make a move first or load a game."); + return; + } + + setIsLoading(true); + setError(null); + + try { + const bestMove = currentPosition.eval?.bestMove; + const bestMoveSan = bestMove + ? moveLineUciToSan(currentPosition.lastMove.before)(bestMove) + : undefined; + + const params: GeminiMoveAnalysisParams = { + fen: currentPosition.lastMove.before, + move: currentPosition.lastMove, + classification: currentPosition.eval.moveClassification, + bestMove, + bestMoveSan, + }; + + const explanationText = await getExplanation(params, true); + setExplanation(explanationText); + } catch (error) { + setError( + error instanceof Error ? error.message : "Failed to get explanation" + ); + console.error("Failed to get Gemini explanation:", error); + } finally { + setIsLoading(false); + } + }, [ + currentPosition.lastMove, + currentPosition.eval?.moveClassification, + currentPosition.eval?.bestMove, + getExplanation, + setExplanation, + setError, + setIsLoading, + ]); + + const getMoveClassificationColor = ( + classification: MoveClassification + ): string => { + switch (classification) { + case MoveClassification.Blunder: + return "#e53935"; + case MoveClassification.Mistake: + return "#ff7043"; + case MoveClassification.Inaccuracy: + return "#ffa726"; + case MoveClassification.Okay: + return "#dce775"; + case MoveClassification.Excellent: + return "#66bb6a"; + case MoveClassification.Best: + return "#26a69a"; + case MoveClassification.Forced: + return "#78909c"; + case MoveClassification.Opening: + return "#7e57c2"; + case MoveClassification.Perfect: + return "#5c6bc0"; + case MoveClassification.Splendid: + return "#ec407a"; + default: + return "#78909c"; + } + }; + + return ( + <> + + + + + + + + + + + + Gemini AI Chess Assistant + + + + + + + } + iconPosition="start" + /> + } + iconPosition="start" + /> + + + + {tabValue === 0 && ( + + {currentPosition.lastMove && + currentPosition.eval?.moveClassification && ( + + + + {currentPosition.lastMove.color === "w" ? ( + + ) : ( + + )} + + + + {currentPosition.lastMove.color === "w" + ? "White" + : "Black"}{" "} + played + + + {currentPosition.lastMove.san} + + + + + + {currentPosition.eval.moveClassification} + + + )} + + {isLoading ? ( + + + + ) : error ? ( + + + + {error} + + + ) : explanation ? ( + + + {explanation} + + + ) : ( + + + + No explanation available yet. + + + Make a move or select a position to get an explanation + + + )} + + )} + + {tabValue === 1 && ( + + + } + label="Enable Gemini AI" + sx={{ mb: 2 }} + /> + + + API Key + + + Enter your Gemini API key. Get one at{" "} + + Google AI Studio + + + + + setApiKey(e.target.value)} + placeholder="Enter your Gemini API key" + type="password" + disabled={!geminiSettings.enabled} + /> + + + + + + About Gemini AI Chess Assistant + + + This feature uses Google{"'"}s Gemini AI to analyze chess + moves and provide easy-to-understand explanations for why a + move is good, bad, or interesting. It helps players of all + levels understand the strategic and tactical elements of their + games. + + + + )} + + + {tabValue === 0 && ( + + + + )} + + + ); +} diff --git a/src/components/geminiHelpTooltip.tsx b/src/components/geminiHelpTooltip.tsx new file mode 100644 index 0000000..c38d303 --- /dev/null +++ b/src/components/geminiHelpTooltip.tsx @@ -0,0 +1,102 @@ +import { useState, useEffect } from "react"; +import { Box, Paper, Typography, IconButton } from "@mui/material"; +import { Icon } from "@iconify/react"; + +interface GeminiHelpTooltipProps { + onClose: () => void; +} + +export default function GeminiHelpTooltip({ onClose }: GeminiHelpTooltipProps) { + const [isVisible, setIsVisible] = useState(false); + + useEffect(() => { + const timer = setTimeout(() => { + setIsVisible(true); + }, 1000); + + return () => clearTimeout(timer); + }, []); + + if (!isVisible) return null; + + return ( + + + + + + + + New: Gemini AI Explanations + + + + Click the brain icon to get simple, easy-to-understand explanations + for your chess moves! + + + + + + Click this button in the analysis tab + + + + + + + ); +} diff --git a/src/hooks/useGeminiExplanation.ts b/src/hooks/useGeminiExplanation.ts new file mode 100644 index 0000000..2c42762 --- /dev/null +++ b/src/hooks/useGeminiExplanation.ts @@ -0,0 +1,79 @@ +import { useState, useCallback } from "react"; +import { + GeminiExplanation, + GeminiMoveAnalysisParams, + getGeminiMoveExplanation, +} from "@/lib/gemini"; + +export const useGeminiExplanation = () => { + const [explanations, setExplanations] = useState< + Record + >({}); + + const getExplanationKey = (params: GeminiMoveAnalysisParams): string => { + const { fen, move } = params; + return `${fen}_${move.from}_${move.to}`; + }; + + const getExplanation = useCallback( + async (params: GeminiMoveAnalysisParams, forceRefresh = false) => { + const key = getExplanationKey(params); + + if (!forceRefresh && explanations[key] && explanations[key].explanation) { + return explanations[key].explanation; + } + + setExplanations((prev) => ({ + ...prev, + [key]: { loading: true, explanation: prev[key]?.explanation || "" }, + })); + + try { + const explanation = await getGeminiMoveExplanation(params); + + setExplanations((prev) => ({ + ...prev, + [key]: { loading: false, explanation }, + })); + + return explanation; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : "Failed to get explanation"; + + setExplanations((prev) => ({ + ...prev, + [key]: { + loading: false, + explanation: prev[key]?.explanation || "", + error: errorMessage, + }, + })); + + throw error; + } + }, + [explanations] + ); + + const getExplanationState = useCallback( + (params: GeminiMoveAnalysisParams): GeminiExplanation => { + const key = getExplanationKey(params); + return explanations[key] || { loading: false, explanation: "" }; + }, + [explanations] + ); + + const clearExplanations = useCallback(() => { + setExplanations({}); + }, []); + + return { + getExplanation, + getExplanationState, + clearExplanations, + explanations, + }; +}; + +export default useGeminiExplanation; diff --git a/src/lib/gemini.ts b/src/lib/gemini.ts new file mode 100644 index 0000000..99daa61 --- /dev/null +++ b/src/lib/gemini.ts @@ -0,0 +1,168 @@ +import { Move } from "chess.js"; +import { MoveClassification } from "@/types/enums"; +import { logErrorToSentry } from "./sentry"; + +export interface GeminiExplanation { + explanation: string; + loading: boolean; + error?: string; +} + +export interface GeminiMoveAnalysisParams { + fen: string; + move: Move; + classification: MoveClassification; + bestMove?: string; + bestMoveSan?: string; +} + +const getGeminiApiKey = (): string | undefined => { + if (typeof window !== "undefined") { + const settings = localStorage.getItem("geminiSettings"); + if (settings) { + try { + const parsed = JSON.parse(settings); + return parsed.apiKey; + // eslint-disable-next-line @typescript-eslint/no-unused-vars + } catch (e) { + return undefined; + } + } + } + return undefined; +}; + +const GEMINI_API_KEY = getGeminiApiKey(); +const GEMINI_API_URL = + "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash-lite:generateContent"; + +export const getGeminiMoveExplanation = async ( + params: GeminiMoveAnalysisParams +): Promise => { + try { + if (!GEMINI_API_KEY) { + throw new Error( + "Gemini API key not found. Please set your API key in the Gemini settings." + ); + } + + const { fen, move, classification, bestMove, bestMoveSan } = params; + + const prompt = createGeminiPrompt( + fen, + move, + classification, + bestMove, + bestMoveSan + ); + + const response = await fetch(`${GEMINI_API_URL}?key=${GEMINI_API_KEY}`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + contents: [ + { + parts: [ + { + text: prompt, + }, + ], + }, + ], + generationConfig: { + temperature: 0.2, + maxOutputTokens: 800, + }, + }), + }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error( + `Gemini API error: ${errorData.error?.message || response.statusText}` + ); + } + + const data = await response.json(); + const explanation = data.candidates?.[0]?.content?.parts?.[0]?.text; + + if (!explanation) { + throw new Error("No explanation returned from Gemini"); + } + + return explanation; + } catch (error) { + logErrorToSentry(error); + throw error; + } +}; + +const createGeminiPrompt = ( + fen: string, + move: Move, + classification: MoveClassification, + bestMove?: string, + bestMoveSan?: string +): string => { + const moveColor = move.color === "w" ? "White" : "Black"; + const classificationText = getMoveClassificationText(classification); + + let prompt = `You are a helpful chess assistant explaining moves to players. + +FEN position: ${fen} +Move played: ${move.san} by ${moveColor} +Move classification: ${classification} (${classificationText}) +`; + + if (bestMove && bestMoveSan && classification !== MoveClassification.Best) { + prompt += `Best move was: ${bestMoveSan}\n`; + } + + prompt += ` +Provide a brief, clear explanation (50-100 words) of why this move is ${classificationText}. +Explain in simple terms that a beginner would understand. +If relevant, mention tactical or strategic elements. +${classification === MoveClassification.Best ? "Explain why this was the best move." : ""} +${ + classification === MoveClassification.Blunder || + classification === MoveClassification.Mistake + ? `Explain what problem this move creates and why ${bestMoveSan || "the best move"} would have been better.` + : "" +} + +Format your response as a single paragraph without bullet points or headers. +`; + + return prompt; +}; + +const getMoveClassificationText = ( + classification: MoveClassification +): string => { + switch (classification) { + case MoveClassification.Blunder: + return "a blunder (very bad move)"; + case MoveClassification.Mistake: + return "a mistake (bad move)"; + case MoveClassification.Inaccuracy: + return "an inaccuracy (slightly suboptimal move)"; + case MoveClassification.Okay: + return "an okay move (reasonable but not optimal)"; + case MoveClassification.Excellent: + return "an excellent move (very good choice)"; + case MoveClassification.Best: + return "the best move (optimal choice)"; + case MoveClassification.Forced: + return "a forced move (only legal option)"; + case MoveClassification.Opening: + return "a standard opening move"; + case MoveClassification.Perfect: + return "a perfect move (the only good option)"; + case MoveClassification.Splendid: + return "a splendid move (brilliant sacrifice)"; + default: + return classification; + } +}; diff --git a/src/pages/index.tsx b/src/pages/index.tsx index ad6df07..8dc573d 100644 --- a/src/pages/index.tsx +++ b/src/pages/index.tsx @@ -18,6 +18,7 @@ import { useEffect, useState } from "react"; import { Icon } from "@iconify/react"; import EngineSettingsButton from "@/sections/engineSettings/engineSettingsButton"; import GraphTab from "@/sections/analysis/panelBody/graphTab"; +import GeminiExplanationDialog from "@/components/geminiExplanationDialog"; import { PageTitle } from "@/components/pageTitle"; export default function GameAnalysis() { @@ -177,6 +178,8 @@ export default function GameAnalysis() { + {/* Removed GeminiSettingsButton since we're now using the dialog directly */} + ); } diff --git a/src/sections/analysis/panelBody/analysisTab/geminiExplanation.tsx b/src/sections/analysis/panelBody/analysisTab/geminiExplanation.tsx new file mode 100644 index 0000000..2b0c7d3 --- /dev/null +++ b/src/sections/analysis/panelBody/analysisTab/geminiExplanation.tsx @@ -0,0 +1,222 @@ +import { + Box, + CircularProgress, + IconButton, + Paper, + Tooltip, + Typography, +} from "@mui/material"; +import { useAtom, useAtomValue } from "jotai"; +import { + currentExplanationAtom, + currentPositionAtom, + isLoadingExplanationAtom, +} from "../../states"; +import { Icon } from "@iconify/react"; +// eslint-disable-next-line import/no-named-as-default +import useGeminiExplanation from "@/hooks/useGeminiExplanation"; +import { GeminiMoveAnalysisParams } from "@/lib/gemini"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { moveLineUciToSan } from "@/lib/chess"; + +export default function GeminiExplanation() { + const currentPosition = useAtomValue(currentPositionAtom); + const [currentExplanation, setCurrentExplanation] = useAtom( + currentExplanationAtom + ); + const [isLoading, setIsLoading] = useAtom(isLoadingExplanationAtom); + const { getExplanation } = useGeminiExplanation(); + const [error, setError] = useState(null); + const explanationRef = useRef(null); + + const handleExplainMove = useCallback(async () => { + if ( + !currentPosition.lastMove || + !currentPosition.eval?.moveClassification + ) { + return; + } + + setIsLoading(true); + setError(null); + + try { + const bestMove = currentPosition.eval?.bestMove; + const bestMoveSan = bestMove + ? moveLineUciToSan(currentPosition.lastMove.before)(bestMove) + : undefined; + + const params: GeminiMoveAnalysisParams = { + fen: currentPosition.lastMove.before, + move: currentPosition.lastMove, + classification: currentPosition.eval.moveClassification, + bestMove, + bestMoveSan, + }; + + const explanation = await getExplanation(params); + setCurrentExplanation(explanation); + + setTimeout(() => { + explanationRef.current?.scrollIntoView({ + behavior: "smooth", + block: "center", + }); + }, 100); + } catch (error) { + setError( + error instanceof Error ? error.message : "Failed to get explanation" + ); + console.error("Failed to get Gemini explanation:", error); + } finally { + setIsLoading(false); + } + }, [ + currentPosition.lastMove, + currentPosition.eval?.moveClassification, + currentPosition.eval?.bestMove, + getExplanation, + setCurrentExplanation, + setIsLoading, + ]); + + useEffect(() => { + setCurrentExplanation(""); + setError(null); + }, [currentPosition.lastMove?.san, setCurrentExplanation]); + + if (!currentPosition.lastMove || !currentPosition.eval?.moveClassification) { + return null; + } + + return ( + + + + Gemini AI Explanation + + + + {isLoading ? ( + + ) : ( + + )} + + + + + + {currentExplanation ? ( + + + + {currentExplanation} + + + + ) : error ? ( + + + {error} + + + ) : ( + + + + Click the brain icon to get an AI explanation for this move + + + )} + + + ); +} diff --git a/src/sections/analysis/panelBody/analysisTab/index.tsx b/src/sections/analysis/panelBody/analysisTab/index.tsx index 7e709ff..db9d83a 100644 --- a/src/sections/analysis/panelBody/analysisTab/index.tsx +++ b/src/sections/analysis/panelBody/analysisTab/index.tsx @@ -41,8 +41,9 @@ export default function AnalysisTab(props: GridProps) { {gameEval && ( )} + + {/* GeminiExplanation component has been moved to a dialog */} diff --git a/src/sections/analysis/panelBody/analysisTab/moveInfo.tsx b/src/sections/analysis/panelBody/analysisTab/moveInfo.tsx index 4e84e51..877e414 100644 --- a/src/sections/analysis/panelBody/analysisTab/moveInfo.tsx +++ b/src/sections/analysis/panelBody/analysisTab/moveInfo.tsx @@ -1,15 +1,21 @@ -import { Skeleton, Stack, Typography } from "@mui/material"; +import { Box, Skeleton, Stack, Tooltip, Typography } from "@mui/material"; import { useAtomValue } from "jotai"; -import { boardAtom, currentPositionAtom } from "../../states"; +import { + boardAtom, + currentPositionAtom, + geminiSettingsAtom, +} from "../../states"; import { useMemo } from "react"; import { moveLineUciToSan } from "@/lib/chess"; import { MoveClassification } from "@/types/enums"; import Image from "next/image"; import PrettyMoveSan from "@/components/prettyMoveSan"; +import { Icon } from "@iconify/react"; export default function MoveInfo() { const position = useAtomValue(currentPositionAtom); const board = useAtomValue(boardAtom); + const geminiSettings = useAtomValue(geminiSettingsAtom); const bestMove = position?.lastEval?.bestMove; @@ -72,16 +78,37 @@ export default function MoveInfo() { }} /> - + + + + {geminiSettings.enabled && ( + + + + + + )} + )} diff --git a/src/sections/analysis/states.ts b/src/sections/analysis/states.ts index a294bdf..715f099 100644 --- a/src/sections/analysis/states.ts +++ b/src/sections/analysis/states.ts @@ -2,6 +2,7 @@ import { DEFAULT_ENGINE } from "@/constants"; import { getRecommendedWorkersNb } from "@/lib/engine/worker"; import { EngineName } from "@/types/enums"; import { CurrentPosition, GameEval, SavedEvals } from "@/types/eval"; +import { GeminiSettings } from "@/types/gemini"; import { Chess } from "chess.js"; import { atom } from "jotai"; import { atomWithStorage } from "jotai/utils"; @@ -25,3 +26,14 @@ export const engineWorkersNbAtom = atomWithStorage( export const evaluationProgressAtom = atom(0); export const savedEvalsAtom = atom({}); + +// Gemini integration +export const geminiSettingsAtom = atomWithStorage( + "geminiSettings", + { + enabled: true, + autoExplain: false, + } +); +export const currentExplanationAtom = atom(""); +export const isLoadingExplanationAtom = atom(false); diff --git a/src/sections/geminiSettings/geminiSettingsButton.tsx b/src/sections/geminiSettings/geminiSettingsButton.tsx new file mode 100644 index 0000000..ba57ee9 --- /dev/null +++ b/src/sections/geminiSettings/geminiSettingsButton.tsx @@ -0,0 +1,197 @@ +import { Icon } from "@iconify/react"; +import { + Box, + Fab, + FormControlLabel, + FormGroup, + IconButton, + Paper, + Switch, + TextField, + Tooltip, + Typography, +} from "@mui/material"; +import { useAtom } from "jotai"; +import { useEffect, useState } from "react"; +import { geminiSettingsAtom } from "../analysis/states"; +import GeminiHelpTooltip from "@/components/geminiHelpTooltip"; + +export default function GeminiSettingsButton() { + const [isOpen, setIsOpen] = useState(false); + const [geminiSettings, setGeminiSettings] = useAtom(geminiSettingsAtom); + const [apiKey, setApiKey] = useState(""); + const [showHelp, setShowHelp] = useState(false); + + useEffect(() => { + setApiKey(geminiSettings.apiKey || ""); + + const hasSeenHelp = localStorage.getItem("gemini_help_seen"); + if (geminiSettings.enabled && !hasSeenHelp) { + setShowHelp(true); + localStorage.setItem("gemini_help_seen", "true"); + } + }, [geminiSettings.apiKey, geminiSettings.enabled]); + + const handleOpen = () => { + setIsOpen(true); + }; + + const handleClose = () => { + setIsOpen(false); + }; + + const handleSave = () => { + setGeminiSettings({ + ...geminiSettings, + apiKey: apiKey.trim(), + }); + setIsOpen(false); + }; + + const handleToggleEnabled = () => { + setGeminiSettings({ + ...geminiSettings, + enabled: !geminiSettings.enabled, + }); + }; + + const handleToggleAutoExplain = () => { + setGeminiSettings({ + ...geminiSettings, + autoExplain: !geminiSettings.autoExplain, + }); + }; + + return ( + <> + + + + + + + + + {showHelp && setShowHelp(false)} />} + + {isOpen && ( + + e.stopPropagation()} + elevation={6} + > + + Gemini AI Settings + + + + + + + + } + label="Enable Gemini AI" + /> + + } + label="Auto-explain moves" + /> + + + + API Key + + + Enter your Gemini API key. Get one at{" "} + + Google AI Studio + + + setApiKey(e.target.value)} + placeholder="Enter your Gemini API key" + type="password" + sx={{ marginBottom: 2 }} + disabled={!geminiSettings.enabled} + /> + + + + + + + + + )} + + ); +} diff --git a/src/types/gemini.ts b/src/types/gemini.ts new file mode 100644 index 0000000..a0c1ead --- /dev/null +++ b/src/types/gemini.ts @@ -0,0 +1,32 @@ +import { MoveClassification } from "./enums"; +import { Move } from "chess.js"; + +export interface GeminiSettings { + enabled: boolean; + autoExplain: boolean; + apiKey?: string; +} + +export interface GeminiExplanationData { + explanation: string; + classification: MoveClassification; + timestamp: number; +} + +export interface ExplainedMove { + fen: string; + move: Move; + explanation: string; + classification: MoveClassification; +} + +export interface GeminiExplanationRequest { + fen: string; + move: Move; + bestMove?: string; +} + +export interface GeminiExplanationResponse { + explanation: string; + error?: string; +}