Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
552 changes: 552 additions & 0 deletions src/components/geminiExplanationDialog.tsx

Large diffs are not rendered by default.

102 changes: 102 additions & 0 deletions src/components/geminiHelpTooltip.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Box
sx={{
position: "fixed",
bottom: 85,
left: "50%",
transform: "translateX(-50%)",
zIndex: 100,
maxWidth: 280,
animation: "fadeIn 0.5s",
"@keyframes fadeIn": {
"0%": {
opacity: 0,
transform: "translateY(20px)",
},
"100%": {
opacity: 1,
transform: "translateY(0)",
},
},
}}
>
<Paper
elevation={4}
sx={{
p: 2,
borderRadius: 2,
position: "relative",
borderLeft: 4,
borderColor: "primary.main",
}}
>
<IconButton
size="small"
onClick={onClose}
sx={{
position: "absolute",
top: 4,
right: 4,
}}
>
<Icon icon="mdi:close" width={16} />
</IconButton>

<Typography
variant="subtitle2"
sx={{ mb: 1, fontWeight: "bold", pr: 3 }}
>
New: Gemini AI Explanations
</Typography>

<Typography variant="body2" sx={{ mb: 1 }}>
Click the brain icon to get simple, easy-to-understand explanations
for your chess moves!
</Typography>

<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
<Icon icon="ic:baseline-psychology-alt" width={18} color="primary" />
<Typography variant="caption" color="text.secondary">
Click this button in the analysis tab
</Typography>
</Box>
</Paper>

<Box
sx={{
width: 0,
height: 0,
borderLeft: "8px solid transparent",
borderRight: "8px solid transparent",
borderTop: "12px solid #fff",
position: "absolute",
bottom: -12,
left: "50%",
transform: "translateX(-50%)",
filter: "drop-shadow(0 2px 2px rgba(0,0,0,0.2))",
}}
/>
</Box>
);
}
79 changes: 79 additions & 0 deletions src/hooks/useGeminiExplanation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { useState, useCallback } from "react";
import {
GeminiExplanation,
GeminiMoveAnalysisParams,
getGeminiMoveExplanation,
} from "@/lib/gemini";

export const useGeminiExplanation = () => {
const [explanations, setExplanations] = useState<
Record<string, GeminiExplanation>
>({});

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;
168 changes: 168 additions & 0 deletions src/lib/gemini.ts
Original file line number Diff line number Diff line change
@@ -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<string> => {
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;
}
};
3 changes: 3 additions & 0 deletions src/pages/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -177,6 +178,8 @@ export default function GameAnalysis() {
</Grid>

<EngineSettingsButton />
{/* Removed GeminiSettingsButton since we're now using the dialog directly */}
<GeminiExplanationDialog />
</Grid>
);
}
Loading