diff --git a/README.md b/README.md index 374c483..ad7e7b4 100644 --- a/README.md +++ b/README.md @@ -36,8 +36,9 @@ This project is developed in Firebase Studio. - [Tailwind CSS](https://tailwindcss.com/) (Utility-first CSS framework) - [Lucide React](https://lucide.dev/) (Icons) - **AI & Backend Logic**: - - [Genkit (by Google)](https://firebase.google.com/docs/genkit): For orchestrating AI flows and interacting with generative models. - - [Google AI (Gemini Models)](https://ai.google.dev/): Powering the AI analysis and suggestions. + - AI-powered features (game analysis, suggestions) are provided via Next.js API Routes. + - Analysis primarily utilizes the [Lichess API](https://lichess.org/api) for Stockfish cloud evaluation. + - Direct integration with generative models like Google's Gemini is planned for future enhancements. - **Authentication & Backend Services**: - [Firebase](https://firebase.google.com/) - Firebase Authentication (Google, Lichess via Custom Auth, Anonymous) @@ -83,14 +84,6 @@ Replace `your_...` with your actual credentials. You'll need to set up a Firebas ``` The application will be available at `http://localhost:9002` (or the port specified in `package.json`). -3. **Run the Genkit development server** (in a separate terminal): - ```bash - npm run genkit:dev - # or for watching changes - npm run genkit:watch - ``` - This starts the Genkit development environment, typically on `http://localhost:4000`. - ## 🔮 Planned Features & Next Steps - **Full Lichess API Integration**: Fetch actual game history and user data. - **Firebase Custom Token Flow for Lichess**: Complete the Lichess sign-in by implementing the Firebase Cloud Function for token exchange. diff --git a/package.json b/package.json index 01e921e..e2d1b38 100644 --- a/package.json +++ b/package.json @@ -4,16 +4,12 @@ "private": true, "scripts": { "dev": "next dev --turbopack", - "genkit:dev": "genkit start -- tsx src/ai/dev.ts", - "genkit:watch": "genkit start -- tsx --watch src/ai/dev.ts", "build": "next build", "start": "next start", "lint": "next lint", "typecheck": "tsc --noEmit" }, "dependencies": { - "@genkit-ai/googleai": "^1.8.0", - "@genkit-ai/next": "^1.8.0", "@hookform/resolvers": "^4.1.3", "@radix-ui/react-accordion": "^1.2.3", "@radix-ui/react-alert-dialog": "^1.1.6", @@ -40,7 +36,6 @@ "date-fns": "^3.6.0", "dotenv": "^16.5.0", "firebase": "^11.9.1", - "genkit": "^1.8.0", "lucide-react": "^0.475.0", "next": "15.3.3", "patch-package": "^8.0.0", @@ -57,7 +52,6 @@ "@types/node": "^20", "@types/react": "^18", "@types/react-dom": "^18", - "genkit-cli": "^1.8.0", "postcss": "^8", "tailwindcss": "^3.4.1", "typescript": "^5" diff --git a/src/ai/dev.ts b/src/ai/dev.ts deleted file mode 100644 index 361ff0a..0000000 --- a/src/ai/dev.ts +++ /dev/null @@ -1,9 +0,0 @@ - -import { config } from 'dotenv'; -config(); - -import '@/ai/flows/analyze-chess-game.ts'; -import '@/ai/flows/training-bot-analysis.ts'; -import '@/ai/flows/generate-improvement-tips.ts'; -import '@/ai/flows/fetch-game-history.ts'; -import '@/ai/flows/deep-analyze-game-metrics.ts'; diff --git a/src/ai/flows/deep-analyze-game-metrics.ts b/src/ai/flows/deep-analyze-game-metrics.ts deleted file mode 100644 index dcf669d..0000000 --- a/src/ai/flows/deep-analyze-game-metrics.ts +++ /dev/null @@ -1,150 +0,0 @@ - -'use server'; -/** - * @fileOverview Performs a simplified analysis of a user's game history using Lichess-based - * analysis for individual games. This version avoids LLM calls. - * - * - deepAnalyzeGameMetrics - Analyzes game PGNs and provides insights. - * - DeepAnalyzeGameMetricsInput - Input type for the flow. - * - DeepAnalyzeGameMetricsOutput - Output type for the flow. - */ - -import {ai}from '@/ai/genkit'; -import {z}from 'genkit'; -import { analyzeChessGame as analyzeSingleChessGameWithLichess, type AnalyzeChessGameOutput } from './analyze-chess-game'; // Import the Lichess-based analyzer - -const DeepAnalyzeGameMetricsInputSchema = z.object({ - gamePgns: z.array(z.string()).min(1).describe("An array of chess games in PGN format (at least one game required)."), - playerUsername: z.string().optional().describe("The username of the player whose games are being analyzed, for context."), -}); -export type DeepAnalyzeGameMetricsInput = z.infer; - -// Output schema is kept similar, but content will be more generic -const WeaknessSchema = z.object({ - name: z.string().describe("Concise name of the weakness (e.g., 'High Blunder Rate', 'Frequent Mistakes')."), - description: z.string().describe("Detailed explanation of the weakness based on aggregated Lichess analysis."), - severity: z.enum(["high", "medium", "low"]).describe("Assessed severity of the weakness."), - icon: z.enum(["AlertTriangle", "Puzzle", "BrainCircuit", "TrendingDown", "BarChart3", "Info"]).optional().describe("Suggested Lucide icon name."), - trainingSuggestion: z.object({ - text: z.string().describe("A concrete training suggestion."), - link: z.string().optional().describe("A relative path to a training page.") - }).describe("A concrete training suggestion to address the weakness.") -}); - -const DeepAnalyzeGameMetricsOutputSchema = z.object({ - overallSummary: z.string().describe("A brief overall summary based on aggregated Lichess game analyses."), - primaryWeaknesses: z.array(WeaknessSchema).min(1).max(2).describe("An array of 1 to 2 primary areas for improvement."), -}); -export type DeepAnalyzeGameMetricsOutput = z.infer; - - -async function analyzeMetricsWithLichess(input: DeepAnalyzeGameMetricsInput): Promise { - let totalGamesAnalyzed = 0; - let gamesWithBlunders = 0; - let gamesWithMistakes = 0; - // In a more complex version, we could count total blunders/mistakes across all games. - // This requires `analyzeSingleChessGameWithLichess` to return structured counts. - // For now, we'll base it on the presence of keywords in the analysis string. - - if (input.gamePgns.length === 0) { - return { - overallSummary: "No games provided for analysis.", - primaryWeaknesses: [{ - name: "No Games", - description: "Please import games to get an analysis.", - severity: "low", - icon: "Info", - trainingSuggestion: { text: "Import games via the Analysis page.", link: "/analysis" } - }] - }; - } - - for (const pgn of input.gamePgns) { - if (!pgn.trim()) continue; - try { - const singleGameAnalysis: AnalyzeChessGameOutput = await analyzeSingleChessGameWithLichess({ pgn }); - totalGamesAnalyzed++; - if (singleGameAnalysis.analysis.toLowerCase().includes("blunder")) { - gamesWithBlunders++; - } - if (singleGameAnalysis.analysis.toLowerCase().includes("mistake")) { - gamesWithMistakes++; - } - } catch (error) { - console.error("Error analyzing a single game in deepAnalyzeGameMetrics:", error); - // Optionally skip this game or mark as failed analysis - } - } - - let overallSummary = `Analyzed ${totalGamesAnalyzed} game(s). `; - const primaryWeaknesses: z.infer[] = []; - - if (totalGamesAnalyzed === 0 && input.gamePgns.length > 0) { - overallSummary = "Could not analyze the provided games using Lichess Stockfish."; - primaryWeaknesses.push({ - name: "Analysis Failed", - description: "Failed to process games with Lichess Stockfish. Check individual game analysis or PGN validity.", - severity: "high", - icon: "AlertTriangle", - trainingSuggestion: { text: "Try analyzing a single game first.", link: "/analysis" } - }); - } else if (gamesWithBlunders > totalGamesAnalyzed / 2 || gamesWithBlunders >= 3) { // Example thresholds - overallSummary += `A high number of games contained blunders (${gamesWithBlunders}/${totalGamesAnalyzed}).`; - primaryWeaknesses.push({ - name: "Reduce Blunders", - description: `Stockfish analysis identified blunders in ${gamesWithBlunders} out of ${totalGamesAnalyzed} games. Focus on minimizing major errors by carefully checking your moves.`, - severity: "high", - icon: "AlertTriangle", - trainingSuggestion: { text: "Practice tactical puzzles and play longer time control games.", link: "/learn/puzzles" } - }); - } else if (gamesWithMistakes > totalGamesAnalyzed / 2 || gamesWithMistakes >= 3) { - overallSummary += `Several games contained mistakes (${gamesWithMistakes}/${totalGamesAnalyzed}).`; - primaryWeaknesses.push({ - name: "Minimize Mistakes", - description: `Stockfish analysis identified mistakes in ${gamesWithMistakes} out of ${totalGamesAnalyzed} games. Review these positions to understand better alternatives.`, - severity: "medium", - icon: "TrendingDown", - trainingSuggestion: { text: "Analyze your games thoroughly, especially after a loss or a complicated game.", link: "/analysis" } - }); - } else { - overallSummary += "No consistent pattern of frequent blunders or mistakes was found across the analyzed games. Keep practicing!"; - primaryWeaknesses.push({ - name: "Consistent Practice", - description: "Your games show a reasonable level of play according to Stockfish. Continue to practice, study, and analyze to steadily improve all aspects of your game.", - severity: "low", - icon: "BrainCircuit", - trainingSuggestion: { text: "Explore different openings or study endgame principles.", link: "/learn/openings" } - }); - } - - if (primaryWeaknesses.length === 0) { // Fallback - primaryWeaknesses.push({ - name: "General Improvement", - description: "Analyze your games regularly to find areas for improvement. Every game is a learning opportunity.", - severity: "medium", - icon: "Puzzle", - trainingSuggestion: { text: "Use the game analysis tools and practice regularly.", link: "/analysis" } - }); - } - - - return { - overallSummary, - primaryWeaknesses: primaryWeaknesses.slice(0,2) // Limit to 1-2 main points - }; -} - - -// Exported function that invokes the Genkit flow -export async function deepAnalyzeGameMetrics(input: DeepAnalyzeGameMetricsInput): Promise { - return deepAnalyzeGameMetricsFlow(input); -} - -const deepAnalyzeGameMetricsFlow = ai.defineFlow( - { - name: 'deepAnalyzeGameMetricsFlow', - inputSchema: DeepAnalyzeGameMetricsInputSchema, - outputSchema: DeepAnalyzeGameMetricsOutputSchema, - }, - analyzeMetricsWithLichess // Use the Lichess-based implementation -); diff --git a/src/ai/flows/fetch-game-history.ts b/src/ai/flows/fetch-game-history.ts deleted file mode 100644 index cbe675b..0000000 --- a/src/ai/flows/fetch-game-history.ts +++ /dev/null @@ -1,94 +0,0 @@ - -'use server'; -/** - * @fileOverview Fetches chess game history for a user from a specified platform. - * Lichess is fetched via API. Other platforms return empty as LLM use is removed. - * - * - fetchGameHistory - A function that fetches recent games. - * - FetchGameHistoryInput - The input type for the fetchGameHistory function. - * - FetchGameHistoryOutput - The return type for the fetchGameHistory function. - */ - -import {ai}from '@/ai/genkit'; -import {z}from 'genkit'; - -const FetchGameHistoryInputSchema = z.object({ - platform: z.enum(["lichess", "chesscom", "chess24"]).describe('The chess platform (e.g., "lichess", "chesscom", "chess24").'), - username: z.string().describe('The username on the specified platform.'), - maxGames: z.number().optional().default(10).describe('Maximum number of games to fetch.'), -}); -export type FetchGameHistoryInput = z.infer; - -const FetchGameHistoryOutputSchema = z.object({ - games: z.array(z.string().describe("A game in PGN format.")).describe("An array of game PGNs. Returns an empty array if no games are found or an error occurs.") -}); -export type FetchGameHistoryOutput = z.infer; - -// The main logic is moved into this implementation function -async function fetchGameHistoryImplementation(input: FetchGameHistoryInput): Promise { - if (input.platform === "lichess") { - try { - console.log(`Fetching Lichess games for ${input.username}, max: ${input.maxGames}`); - // Common parameters for fetching PGNs: - // literate=true (includes comments like clock times if available) - // tags=true (includes standard PGN tags) - // opening=true (includes ECO code and opening name if Lichess recognizes it) - // players=true (includes player details) - const lichessApiUrl = new URL(`https://lichess.org/api/games/user/${input.username}`); - lichessApiUrl.searchParams.set('max', String(input.maxGames)); - lichessApiUrl.searchParams.set('pgns', 'true'); - lichessApiUrl.searchParams.set('literate', 'true'); - lichessApiUrl.searchParams.set('tags', 'true'); - lichessApiUrl.searchParams.set('opening', 'true'); - // lichessApiUrl.searchParams.set('clocks', 'true'); // To include clock comments - // lichessApiUrl.searchParams.set('evals', 'false'); // Evals not needed here, analyze separately - - const response = await fetch( - lichessApiUrl.toString(), - { - headers: { 'Accept': 'application/x-nd-pgn' } - } - ); - - if (!response.ok) { - console.error(`Lichess API error for ${input.username}: ${response.status} ${response.statusText}`); - const errorBody = await response.text(); - console.error("Lichess API error body:", errorBody); - return { games: [] }; - } - - const textData = await response.text(); - const games = textData.trim().split(/\n\n\n|\r\n\r\n\r\n/).filter(pgn => pgn.trim().startsWith('[Event') && pgn.length > 20); - console.log(`Fetched ${games.length} games from Lichess for ${input.username}`); - return { games }; - - } catch (error) { - console.error(`Failed to fetch games from Lichess for ${input.username}:`, error); - return { games: [] }; // Return empty on error - } - } else if (input.platform === "chesscom") { - console.log(`Chess.com game fetching for ${input.username} not implemented with direct API (requires auth or different API). Returning empty.`); - return { games: [] }; - } else if (input.platform === "chess24") { - console.log(`Chess24 game fetching for ${input.username} not implemented. Returning empty.`); - return { games: [] }; - } - - console.warn(`Platform ${input.platform} not implemented for game fetching or LLM fallback removed. Returning empty array.`); - return { games: [] }; -} - -// Exported function that invokes the Genkit flow -export async function fetchGameHistory(input: FetchGameHistoryInput): Promise { - return fetchGameHistoryFlow(input); -} - -const fetchGameHistoryFlow = ai.defineFlow( - { - name: 'fetchGameHistoryFlow', - inputSchema: FetchGameHistoryInputSchema, - outputSchema: FetchGameHistoryOutputSchema, - }, - fetchGameHistoryImplementation -); -// Removed the ai.definePrompt for 'fetchGameHistoryPrompt' as it's no longer used. diff --git a/src/ai/flows/generate-improvement-tips.ts b/src/ai/flows/generate-improvement-tips.ts deleted file mode 100644 index fcda95f..0000000 --- a/src/ai/flows/generate-improvement-tips.ts +++ /dev/null @@ -1,87 +0,0 @@ - -'use server'; - -/** - * @fileOverview A chess improvement tips generator based on Lichess Stockfish analysis summary. - * This version does not use an LLM and provides rule-based tips. - * - * - generateImprovementTips - A function that generates improvement tips. - * - GenerateImprovementTipsInput - The input type for the generateImprovementTips function. - * - GenerateImprovementTipsOutput - The return type for the generateImprovementTips function. - */ - -import {ai}from '@/ai/genkit'; -import {z}from 'genkit'; - -const GenerateImprovementTipsInputSchema = z.object({ - gameAnalysis: z - .string() - .describe( - 'A textual summary of chess game analysis, expected to come from Lichess Stockfish (e.g., highlighting blunders, mistakes).' - ), -}); -export type GenerateImprovementTipsInput = z.infer; - -const GenerateImprovementTipsOutputSchema = z.object({ - tips: z - .array(z.string()) - .describe( - 'An array of 3-5 plain-text improvement tips based on the game analysis summary.' - ), -}); -export type GenerateImprovementTipsOutput = z.infer; - - -async function generateRuleBasedTips(input: GenerateImprovementTipsInput): Promise { - const tips: string[] = []; - const analysisText = input.gameAnalysis.toLowerCase(); - - if (analysisText.includes("blunder")) { - tips.push("One or more blunders were identified. Focus on improving your tactical vision and calculation. Regularly solving puzzles can help. [Practice Puzzles on Lichess](https://lichess.org/training)"); - } - if (analysisText.includes("mistake")) { - tips.push("Mistakes were made that significantly changed the evaluation. Review these moments carefully. Understanding why a mistake occurred is crucial for avoiding it in the future. [Analyze your games on Lichess](https://lichess.org/analysis)"); - } - if (analysisText.includes("inaccuracy")) { - tips.push("Inaccuracies can accumulate. Try to find the most precise moves, especially in critical positions. Deeper calculation or better positional understanding might be needed."); - } - - if (tips.length < 2 && analysisText.includes("stockfish")) { - tips.push("Review the game with Stockfish to understand key moments and alternative lines. [Use Lichess Analysis Board](https://lichess.org/analysis)"); - } - - if (tips.length === 0) { - tips.push("The analysis didn't pinpoint specific frequent errors. Consistent practice and reviewing your games are always beneficial. [Play a game on Lichess](https://lichess.org/play)"); - } - - // Add a general tip if space allows - if (tips.length < 3) { - tips.push("Consider studying common openings and their typical middlegame plans to get a better start. [Explore Openings on Lichess](https://lichess.org/opening)"); - } - if (tips.length < 4 && (analysisText.includes("blunder") || analysisText.includes("mistake"))) { - tips.push("Double-check your moves for simple tactical oversights before committing, especially checking for undefended pieces or potential forks/pins."); - } - - - // Ensure between 1 and 4 tips. - const finalTips = tips.slice(0, 4); - if (finalTips.length === 0) { // Ensure at least one tip - finalTips.push("Keep practicing and learning! Every game is an opportunity to improve. [Play a game on Lichess](https://lichess.org/play)"); - } - - return { tips: finalTips }; -} - -// Exported function that invokes the Genkit flow -export async function generateImprovementTips(input: GenerateImprovementTipsInput): Promise { - return generateImprovementTipsFlow(input); -} - -const generateImprovementTipsFlow = ai.defineFlow( - { - name: 'generateImprovementTipsFlow', - inputSchema: GenerateImprovementTipsInputSchema, - outputSchema: GenerateImprovementTipsOutputSchema, - }, - generateRuleBasedTips // Use the rule-based tip generation -); diff --git a/src/ai/flows/training-bot-analysis.ts b/src/ai/flows/training-bot-analysis.ts deleted file mode 100644 index 55a2839..0000000 --- a/src/ai/flows/training-bot-analysis.ts +++ /dev/null @@ -1,113 +0,0 @@ - -'use server'; - -/** - * @fileOverview Provides a training bot that analyzes current FEN using Lichess Stockfish - * and suggests moves during a live game. - * - * - analyzeGameAndSuggestMove - A function that analyzes the current game state and provides a move suggestion. - * - TrainingBotInput - The input type for the analyzeGameAndSuggestMove function. - * - TrainingBotOutput - The return type for the analyzeGameAndSuggestMove function. - */ - -import {ai} from '@/ai/genkit'; -import {z}from 'genkit'; - -const TrainingBotInputSchema = z.object({ - // gameHistory is not used by Lichess FEN analysis directly, but kept for schema compatibility - gameHistory: z.string().optional().describe('The game history in PGN format of the user (optional for Lichess FEN analysis).'), - currentBoardState: z.string().describe('The current board state in FEN format.'), - // moveNumber is not used by Lichess FEN analysis directly - moveNumber: z.number().optional().describe('The current move number in the game (optional).'), -}); -export type TrainingBotInput = z.infer; - -const TrainingBotOutputSchema = z.object({ - suggestedMove: z.string().describe('The suggested move in UCI notation (e.g., e2e4).'), - evaluation: z.number().describe('The evaluation of the current board state from White\'s perspective (positive is good for white, in centipawns).'), - explanation: z.string().describe('Explanation of the suggestion source.'), -}); -export type TrainingBotOutput = z.infer; - - -async function getLichessFenAnalysis(fen: string): Promise<{ moveUci?: string, evaluationCp?: number, mate?: number } | null> { - try { - const response = await fetch(`https://lichess.org/api/cloud-eval?fen=${encodeURIComponent(fen)}`, { - headers: { 'Accept': 'application/x-ndjson' } - }); - if (!response.ok) { - const errorBody = await response.text(); - console.error(`Lichess API error for FEN cloud-eval: ${response.status} ${response.statusText}`, errorBody); - return null; - } - const ndjson = await response.text(); - // For FEN, Lichess often returns a single line if cached, or streams if calculating. - // We'll take the first valid line with PVs. - const lines = ndjson.trim().split('\n'); - for (const line of lines) { - if (line.trim()) { - const data = JSON.parse(line); - if (data.pvs && data.pvs.length > 0) { - const bestPv = data.pvs[0]; - return { - moveUci: bestPv.moves?.split(' ')[0], // Get the first move of the principal variation - evaluationCp: bestPv.cp, - mate: bestPv.mate, - }; - } - // Sometimes, the top-level object directly contains the eval if it's a simple lookup - if (data.cp || data.mate) { - return { - moveUci: undefined, // No PV given in this format, just eval - evaluationCp: data.cp, - mate: data.mate, - }; - } - } - } - console.warn("Lichess FEN Analysis: No suitable PV found in response.", ndjson); - return null; - } catch (error) { - console.error("Failed to fetch or parse Lichess FEN cloud evaluation:", error); - return null; - } -} - -async function analyzeWithLichessStockfish(input: TrainingBotInput): Promise { - const analysis = await getLichessFenAnalysis(input.currentBoardState); - - if (!analysis) { - return { - suggestedMove: "N/A", - evaluation: 0, - explanation: "Could not get analysis from Lichess Stockfish.", - }; - } - - let finalEval = 0; - if (typeof analysis.evaluationCp === 'number') { - finalEval = analysis.evaluationCp; - } else if (typeof analysis.mate === 'number') { - finalEval = analysis.mate > 0 ? 10000 : -10000; // Convert mate to large CP value - } - - return { - suggestedMove: analysis.moveUci || "N/A (eval only)", - evaluation: finalEval, - explanation: `Lichess Stockfish evaluation. Eval: ${finalEval/100.0} (CP: ${finalEval}). ${analysis.mate ? `Mate in ${analysis.mate}.` : ''}`, - }; -} - -// Exported function that invokes the Genkit flow -export async function analyzeGameAndSuggestMove(input: TrainingBotInput): Promise { - return trainingBotFlow(input); -} - -const trainingBotFlow = ai.defineFlow( - { - name: 'trainingBotFlow', - inputSchema: TrainingBotInputSchema, - outputSchema: TrainingBotOutputSchema, - }, - analyzeWithLichessStockfish // Use the Lichess Stockfish implementation -); diff --git a/src/ai/genkit.ts b/src/ai/genkit.ts deleted file mode 100644 index 6d7f5ec..0000000 --- a/src/ai/genkit.ts +++ /dev/null @@ -1,11 +0,0 @@ - -import {genkit} from 'genkit'; -// googleAI plugin removed - -export const ai = genkit({ - plugins: [ - // googleAI plugin removed - ], - // No default model needed if not using LLM-based prompts extensively. - // Flows will now primarily use direct API calls (e.g., to Lichess). -}); diff --git a/src/app/(app)/analysis/page.tsx b/src/app/(app)/analysis/page.tsx index 3c143bd..f62576e 100644 --- a/src/app/(app)/analysis/page.tsx +++ b/src/app/(app)/analysis/page.tsx @@ -18,9 +18,15 @@ import { FileText, Lightbulb, Loader2, AlertCircle, CheckCircle, UserSearch, Bar import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"; import { useToast } from "@/hooks/use-toast"; -import { analyzeChessGame, AnalyzeChessGameOutput } from '@/ai/flows/analyze-chess-game'; -import { generateImprovementTips, GenerateImprovementTipsOutput } from '@/ai/flows/generate-improvement-tips'; -import { fetchGameHistory, FetchGameHistoryOutput } from '@/ai/flows/fetch-game-history'; +// Import types from the centralized api-schemas file +import { + type AnalyzeChessGameOutput, + type GenerateImprovementTipsOutput, + type FetchGameHistoryOutput, + type FetchGameHistoryInput +} from '@/lib/types/api-schemas'; +import { apiClient } from '@/lib/api-client'; + const pgnImportSchema = z.object({ pgn: z.string().min(10, { message: "PGN data seems too short." }).max(30000, { message: "PGN data is too long (max 30k chars)." }), @@ -58,12 +64,12 @@ export default function AnalysisPage() { return; } try { - const analysis = await analyzeChessGame({ pgn: pgnData }); + const analysis = await apiClient.analyzeChessGame({ pgn: pgnData }); setAnalysisResult(analysis); toast({ title: `Game from ${source} Analyzed`, description: "Stockfish analysis complete.", variant: "default" }); if (analysis?.analysis) { - const tips = await generateImprovementTips({ gameAnalysis: analysis.analysis }); + const tips = await apiClient.generateImprovementTips({ gameAnalysis: analysis.analysis }); setImprovementTips(tips); toast({ title: "Tips Generated", description: "Improvement suggestions are ready.", variant: "default" }); } @@ -89,7 +95,14 @@ export default function AnalysisPage() { setImprovementTips(null); try { toast({ title: "Fetching Games...", description: `Attempting to fetch games for ${data.username} from ${data.platform}.`, variant: "default"}); - const historyOutput = await fetchGameHistory({ platform: data.platform as "lichess" | "chesscom" | "chess24", username: data.username, maxGames: 1 }); + + // Call the new API route for fetchGameHistory + const historyInput: FetchGameHistoryInput = { + platform: data.platform as "lichess" | "chesscom" | "chess24", + username: data.username, + maxGames: 1 + }; + const historyOutput = await apiClient.fetchGameHistory(historyInput); if (historyOutput.games && historyOutput.games.length > 0) { toast({ title: "Games Fetched!", description: `Found ${historyOutput.games.length} game(s). Analyzing the latest one.`, variant: "default"}); diff --git a/src/app/(app)/page.tsx b/src/app/(app)/page.tsx index 7b261e9..08623e8 100644 --- a/src/app/(app)/page.tsx +++ b/src/app/(app)/page.tsx @@ -19,9 +19,15 @@ import { zodResolver } from '@hookform/resolvers/zod'; import * as z from 'zod'; import { ResponsiveContainer, LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip as RechartsTooltip, Legend } from 'recharts'; +// Import types from the centralized api-schemas file +import { + type FetchGameHistoryInput, + type FetchGameHistoryOutput, + type DeepAnalyzeGameMetricsInput, + type DeepAnalyzeGameMetricsOutput +} from '@/lib/types/api-schemas'; +import { apiClient } from '@/lib/api-client'; -import { fetchGameHistory, FetchGameHistoryInput } from '@/ai/flows/fetch-game-history'; -import { deepAnalyzeGameMetrics, DeepAnalyzeGameMetricsOutput } from '@/ai/flows/deep-analyze-game-metrics'; interface Insight { id: string; @@ -260,11 +266,10 @@ export default function DashboardPage() { setActiveUsername(data.lichessUsername); try { - const historyInput: FetchGameHistoryInput = { platform: "lichess", username: data.lichessUsername, maxGames: 20 }; // Fetch more for trend - const historyOutput = await fetchGameHistory(historyInput); + const historyInputPayload: FetchGameHistoryInput = { platform: "lichess", username: data.lichessUsername, maxGames: 20 }; + const historyOutput = await apiClient.fetchGameHistory(historyInputPayload); const fetchedParsedGames = historyOutput.games.map(parsePgn).filter(g => g.tags.length > 0); - if (fetchedParsedGames.length > 0) { toast({ title: "Games Fetched!", @@ -281,7 +286,13 @@ export default function DashboardPage() { colSpan: "lg:col-span-full", }]); - const deepAnalysis = await deepAnalyzeGameMetrics({ gamePgns: historyOutput.games, playerUsername: data.lichessUsername }); + const deepAnalysisInput: DeepAnalyzeGameMetricsInput = { + gamePgns: historyOutput.games, + playerUsername: data.lichessUsername, + baseUrl: window.location.origin // apiClient will handle this if not provided client-side + }; + const deepAnalysis = await apiClient.deepAnalyzeGameMetrics(deepAnalysisInput); + processAndDisplayAnalysis(deepAnalysis, data.lichessUsername, fetchedParsedGames); toast({ title: "Analysis Complete", diff --git a/src/ai/flows/analyze-chess-game.ts b/src/app/api/ai/analyze-chess-game/route.ts similarity index 57% rename from src/ai/flows/analyze-chess-game.ts rename to src/app/api/ai/analyze-chess-game/route.ts index d14c9bb..47f803e 100644 --- a/src/ai/flows/analyze-chess-game.ts +++ b/src/app/api/ai/analyze-chess-game/route.ts @@ -1,30 +1,7 @@ +import { NextResponse } from 'next/server'; +import { AnalyzeChessGameInputSchema, type AnalyzeChessGameInput, type AnalyzeChessGameOutput } from '@/lib/types/api-schemas'; -'use server'; - -/** - * @fileOverview Chess game analysis flow to identify blunders, mistakes, and inaccuracies - * using Lichess Stockfish cloud evaluation. - * - * - analyzeChessGame - Analyzes a chess game provided in PGN format. - * - AnalyzeChessGameInput - The input type for the analyzeChessGame function. - * - AnalyzeChessGameOutput - The return type for the analyzeChessGame function. - */ - -import {ai} from '@/ai/genkit'; -import {z} from 'genkit'; - -const AnalyzeChessGameInputSchema = z.object({ - pgn: z.string().describe('The chess game in PGN format.'), -}); -export type AnalyzeChessGameInput = z.infer; - -const AnalyzeChessGameOutputSchema = z.object({ - analysis: z.string().describe('A summary of the chess game analysis from Lichess Stockfish, highlighting significant evaluation swings (blunders, mistakes, inaccuracies).'), - // Optionally, we could return more structured data in the future - // significantMoves: z.array(z.object({ /* ... */ })).optional(), -}); -export type AnalyzeChessGameOutput = z.infer; - +// Constants and helper types/functions (copied from original) const BLUNDER_THRESHOLD_CP = 200; const MISTAKE_THRESHOLD_CP = 100; const INACCURACY_THRESHOLD_CP = 50; @@ -63,7 +40,8 @@ async function getLichessAnalysis(pgn: string): Promise { } } -async function analyzeChessGameWithLichessImpl(input: AnalyzeChessGameInput): Promise { +// Core logic function (renamed from analyzeChessGameWithLichessImpl) +async function analyzeChessGameLogic(input: AnalyzeChessGameInput): Promise { const lichessEvals = await getLichessAnalysis(input.pgn); if (lichessEvals.length === 0) { @@ -95,9 +73,18 @@ async function analyzeChessGameWithLichessImpl(input: AnalyzeChessGameInput): Pr } const processedEvalsList = []; - if (evals[0].ply > 1) { + // Ensure there's a baseline eval at ply 0 (before any moves) if Lichess data doesn't start from there. + // Lichess cloud eval usually starts providing evals after the first move (ply 1). + // A ply 0 eval is typically 0 (neutral) or a small standard opening book value. We use 0 for simplicity. + if (evals.length > 0 && evals[0].ply > 1) { processedEvalsList.push({ply: 0, cp: 0}); + } else if (evals.length === 0 && lichessEvals.length > 0) { + // This case means lichessEvals had items, but none had valid 'cp' or 'ply > 0'. + // Still, good to have a baseline if we proceed (though current logic returns "No valid evaluation points"). + processedEvalsList.push({ply: 0, cp: 0}); } + + processedEvalsList.push(...evals); @@ -108,10 +95,13 @@ async function analyzeChessGameWithLichessImpl(input: AnalyzeChessGameInput): Pr const player = getPlayerForPly(currentPlyInfo.ply); let cpLossForPlayer = 0; + // White wants higher CP, Black wants lower CP (more negative). + // cpLoss for White: prevEval - currentEval + // cpLoss for Black: -(prevEval - currentEval) which is currentEval - prevEval if (player === 'White') { cpLossForPlayer = prevPlyInfo.cp - currentPlyInfo.cp; - } else { - cpLossForPlayer = -(prevPlyInfo.cp - currentPlyInfo.cp); + } else { // Black's turn + cpLossForPlayer = -(prevPlyInfo.cp - currentPlyInfo.cp); // cp gain for white is cp loss for black } let moveType: 'Blunder' | 'Mistake' | 'Inaccuracy' | null = null; @@ -128,9 +118,9 @@ async function analyzeChessGameWithLichessImpl(input: AnalyzeChessGameInput): Pr moveNumber, type: moveType, cpDrop: Math.round(cpLossForPlayer), - evalBefore: Math.round(prevPlyInfo.cp), - evalAfter: Math.round(currentPlyInfo.cp), - description: `${moveType} by ${player} (move ${playerMoveIndicator.trim()}). Eval for White changed from ${(prevPlyInfo.cp/100).toFixed(2)} to ${(currentPlyInfo.cp/100).toFixed(2)}. Player's loss: ${(cpLossForPlayer/100).toFixed(2)}cp.` + evalBefore: Math.round(player === 'White' ? prevPlyInfo.cp : -prevPlyInfo.cp), // Eval from player's perspective + evalAfter: Math.round(player === 'White' ? currentPlyInfo.cp : -currentPlyInfo.cp), // Eval from player's perspective + description: `${moveType} by ${player} (move ${playerMoveIndicator.trim()}). Eval (White's view) changed from ${(prevPlyInfo.cp/100).toFixed(2)} to ${(currentPlyInfo.cp/100).toFixed(2)}. Player's perspective loss: ${(cpLossForPlayer/100).toFixed(2)}cp.` }); } } @@ -140,21 +130,30 @@ async function analyzeChessGameWithLichessImpl(input: AnalyzeChessGameInput): Pr analysisTextResult += "No significant blunders, mistakes, or inaccuracies detected based on centipawn evaluation swings.\n"; } else { analysisTextResult += significantSwings.map(s => s.description).join("\n"); - analysisTextResult += "\n\n(Note: Player's loss indicates how much their position worsened according to Stockfish after their move.)"; + analysisTextResult += "\n\n(Note: Player's perspective loss indicates how much their position worsened according to Stockfish after their move, from their point of view.)"; } return { analysis: analysisTextResult }; } -export async function analyzeChessGame(input: AnalyzeChessGameInput): Promise { - return analyzeChessGameFlowImpl(input); -} +// Next.js API Route handler +export async function POST(request: Request) { + try { + const body = await request.json(); + const validatedInput = AnalyzeChessGameInputSchema.safeParse(body); + + if (!validatedInput.success) { + return NextResponse.json({ error: 'Invalid input', details: validatedInput.error.flatten() }, { status: 400 }); + } + + const output = await analyzeChessGameLogic(validatedInput.data); + return NextResponse.json(output); -const analyzeChessGameFlowImpl = ai.defineFlow( - { - name: 'analyzeChessGameFlow', - inputSchema: AnalyzeChessGameInputSchema, - outputSchema: AnalyzeChessGameOutputSchema, - }, - analyzeChessGameWithLichessImpl -); + } catch (error) { + console.error('Error in analyze-chess-game API route:', error); + if (error instanceof SyntaxError) { + return NextResponse.json({ error: 'Invalid JSON payload' }, { status: 400 }); + } + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/src/app/api/ai/deep-analyze-game-metrics/route.ts b/src/app/api/ai/deep-analyze-game-metrics/route.ts new file mode 100644 index 0000000..67e44ff --- /dev/null +++ b/src/app/api/ai/deep-analyze-game-metrics/route.ts @@ -0,0 +1,163 @@ +import { NextResponse } from 'next/server'; +import { + DeepAnalyzeGameMetricsInputSchema, + type DeepAnalyzeGameMetricsInput, + type DeepAnalyzeGameMetricsOutput, + type AnalyzeChessGameOutput // This type is used by analyzeSingleGameViaApi +} from '@/lib/types/api-schemas'; + +// Function to call the new analyze-chess-game API route +async function analyzeSingleGameViaApi(pgn: string, baseUrl: string): Promise { + try { + const response = await fetch(`${baseUrl}/api/ai/analyze-chess-game`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ pgn }), + }); + + if (!response.ok) { + const errorBody = await response.text(); + console.error(`API error from analyze-chess-game for PGN: ${response.status}`, errorBody); + return null; + } + return await response.json() as AnalyzeChessGameOutput; + } catch (error) { + console.error('Failed to fetch from analyze-chess-game API:', error); + return null; + } +} + +// Core logic function +async function analyzeMetricsLogic(input: DeepAnalyzeGameMetricsInput): Promise { + let totalGamesAnalyzed = 0; + let gamesWithBlunders = 0; + let gamesWithMistakes = 0; + + if (input.gamePgns.length === 0) { + return { + overallSummary: "No games provided for analysis.", + primaryWeaknesses: [{ + name: "No Games", + description: "Please import games to get an analysis.", + severity: "low", + icon: "Info", + trainingSuggestion: { text: "Import games via the Analysis page.", link: "/analysis" } + }] + }; + } + + for (const pgn of input.gamePgns) { + if (!pgn.trim()) continue; + // Call the new API route for single game analysis + const singleGameAnalysis = await analyzeSingleGameViaApi(pgn, input.baseUrl); + + if (singleGameAnalysis && singleGameAnalysis.analysis) { + totalGamesAnalyzed++; + if (singleGameAnalysis.analysis.toLowerCase().includes("blunder")) { + gamesWithBlunders++; + } + if (singleGameAnalysis.analysis.toLowerCase().includes("mistake")) { + gamesWithMistakes++; + } + } else { + console.warn("Skipping a game in deep analysis due to previous error or no analysis content."); + } + } + + let overallSummary = `Analyzed ${totalGamesAnalyzed} game(s) out of ${input.gamePgns.length} provided. `; + const primaryWeaknesses: z.infer[] = []; + + if (totalGamesAnalyzed === 0 && input.gamePgns.length > 0) { + overallSummary = "Could not analyze the provided games. This might be due to issues with individual game analyses or PGN validity."; + primaryWeaknesses.push({ + name: "Analysis Failed", + description: "Failed to process games. Check individual game analysis or PGN validity if possible.", + severity: "high", + icon: "AlertTriangle", + trainingSuggestion: { text: "Try analyzing a single game first to ensure it works.", link: "/analysis" } + }); + } else if (gamesWithBlunders > totalGamesAnalyzed / 2 || (totalGamesAnalyzed > 0 && gamesWithBlunders >= Math.max(1, Math.min(3, totalGamesAnalyzed)) ) ) { // Adjusted threshold + overallSummary += `A high number of games contained blunders (${gamesWithBlunders}/${totalGamesAnalyzed}).`; + primaryWeaknesses.push({ + name: "Reduce Blunders", + description: `Stockfish analysis identified blunders in ${gamesWithBlunders} out of ${totalGamesAnalyzed} analyzed games. Focus on minimizing major errors by carefully checking your moves.`, + severity: "high", + icon: "AlertTriangle", + trainingSuggestion: { text: "Practice tactical puzzles and play longer time control games.", link: "/learn/puzzles" } + }); + } else if (gamesWithMistakes > totalGamesAnalyzed / 2 || (totalGamesAnalyzed > 0 && gamesWithMistakes >= Math.max(1, Math.min(3, totalGamesAnalyzed)) ) ) { + overallSummary += `Several games contained mistakes (${gamesWithMistakes}/${totalGamesAnalyzed}).`; + primaryWeaknesses.push({ + name: "Minimize Mistakes", + description: `Stockfish analysis identified mistakes in ${gamesWithMistakes} out of ${totalGamesAnalyzed} analyzed games. Review these positions to understand better alternatives.`, + severity: "medium", + icon: "TrendingDown", + trainingSuggestion: { text: "Analyze your games thoroughly, especially after a loss or a complicated game.", link: "/analysis" } + }); + } else if (totalGamesAnalyzed > 0) { + overallSummary += "No consistent pattern of frequent blunders or mistakes was found across the analyzed games. Keep practicing!"; + primaryWeaknesses.push({ + name: "Consistent Practice", + description: "Your games show a reasonable level of play according to Stockfish. Continue to practice, study, and analyze to steadily improve all aspects of your game.", + severity: "low", + icon: "BrainCircuit", + trainingSuggestion: { text: "Explore different openings or study endgame principles.", link: "/learn/openings" } + }); + } + + // Fallback if no specific weaknesses were identified despite games being analyzed + if (totalGamesAnalyzed > 0 && primaryWeaknesses.length === 0) { + primaryWeaknesses.push({ + name: "General Improvement", + description: "Analyze your games regularly to find areas for improvement. Every game is a learning opportunity.", + severity: "medium", + icon: "Puzzle", + trainingSuggestion: { text: "Use the game analysis tools and practice regularly.", link: "/analysis" } + }); + } + + + return { + overallSummary, + primaryWeaknesses: primaryWeaknesses.slice(0,2) // Limit to 1-2 main points + }; +} + +// Next.js API Route handler +export async function POST(request: Request) { + try { + const body = await request.json(); + // Add baseUrl to input for self-API calls + // In a real deployment, this would come from environment variables or request headers + // For local dev, it might be http://localhost:3000 (or whatever port Next.js is on) + // The client calling this API should provide its own origin as the baseUrl + const validatedInput = DeepAnalyzeGameMetricsInputSchema.safeParse(body); + + if (!validatedInput.success) { + return NextResponse.json({ error: 'Invalid input', details: validatedInput.error.flatten() }, { status: 400 }); + } + + if (!validatedInput.data.baseUrl) { + // Try to infer from request headers if not provided, though this is less reliable + const inferredBaseUrl = request.headers.get('origin') || `http://${request.headers.get('host')}`; + if (!inferredBaseUrl.startsWith('http')) { + return NextResponse.json({ error: 'baseUrl is required and could not be reliably inferred.'}, { status: 400 }); + } + validatedInput.data.baseUrl = inferredBaseUrl; + console.warn(`baseUrl for deep-analyze-game-metrics was inferred to: ${validatedInput.data.baseUrl}. It's better if the client provides this explicitly.`); + } + + + const output = await analyzeMetricsLogic(validatedInput.data); + return NextResponse.json(output); + + } catch (error) { + console.error('Error in deep-analyze-game-metrics API route:', error); + if (error instanceof SyntaxError) { + return NextResponse.json({ error: 'Invalid JSON payload' }, { status: 400 }); + } + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/src/app/api/ai/fetch-game-history/route.ts b/src/app/api/ai/fetch-game-history/route.ts new file mode 100644 index 0000000..4e649eb --- /dev/null +++ b/src/app/api/ai/fetch-game-history/route.ts @@ -0,0 +1,88 @@ +import { NextResponse } from 'next/server'; +// Zod is still needed for .safeParse if not using the schema directly from import for that +import { FetchGameHistoryInputSchema, type FetchGameHistoryInput, type FetchGameHistoryOutput } from '@/lib/types/api-schemas'; + +// The main logic for fetching game history +async function fetchGameHistoryLogic(input: FetchGameHistoryInput): Promise { + if (input.platform === "lichess") { + try { + console.log(`Fetching Lichess games for ${input.username}, max: ${input.maxGames}`); + const lichessApiUrl = new URL(`https://lichess.org/api/games/user/${input.username}`); + lichessApiUrl.searchParams.set('max', String(input.maxGames)); + lichessApiUrl.searchParams.set('pgns', 'true'); + lichessApiUrl.searchParams.set('literate', 'true'); + lichessApiUrl.searchParams.set('tags', 'true'); + lichessApiUrl.searchParams.set('opening', 'true'); + + const response = await fetch( + lichessApiUrl.toString(), + { + headers: { 'Accept': 'application/x-nd-pgn' } + } + ); + + if (!response.ok) { + console.error(`Lichess API error for ${input.username}: ${response.status} ${response.statusText}`); + const errorBody = await response.text(); + console.error("Lichess API error body:", errorBody); + return { games: [] }; + } + + const textData = await response.text(); + // Split PGNs. Lichess uses three newlines as a separator for multiple PGNs in application/x-nd-pgn format. + const games = textData.trim().split(/\n\n\n|\r\n\r\n\r\n/).filter(pgn => pgn.trim().startsWith('[Event') && pgn.length > 20); + console.log(`Fetched ${games.length} games from Lichess for ${input.username}`); + return { games }; + + } catch (error) { + console.error(`Failed to fetch games from Lichess for ${input.username}:`, error); + return { games: [] }; + } + } else if (input.platform === "chesscom") { + console.log(`Chess.com game fetching for ${input.username} not implemented. Returning empty.`); + return { games: [] }; + } else if (input.platform === "chess24") { + console.log(`Chess24 game fetching for ${input.username} not implemented. Returning empty.`); + return { games: [] }; + } + + console.warn(`Platform ${input.platform} not implemented. Returning empty array.`); + return { games: [] }; +} + + +export async function POST(request: Request) { + try { + const body = await request.json(); + const validatedInput = FetchGameHistoryInputSchema.safeParse(body); + + if (!validatedInput.success) { + return NextResponse.json({ error: 'Invalid input', details: validatedInput.error.flatten() }, { status: 400 }); + } + + const output = await fetchGameHistoryLogic(validatedInput.data); + return NextResponse.json(output); + + } catch (error) { + console.error('Error in fetch-game-history API route:', error); + if (error instanceof SyntaxError) { // Handle cases where request.json() fails + return NextResponse.json({ error: 'Invalid JSON payload' }, { status: 400 }); + } + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} + +// Optionally, can add a GET handler if you want to pass parameters via URL query +// export async function GET(request: Request) { +// const { searchParams } = new URL(request.url); +// const params = Object.fromEntries(searchParams.entries()); +// // Convert params to appropriate types for validation if needed (e.g., maxGames to number) +// const validatedInput = FetchGameHistoryInputSchema.safeParse(params); +// +// if (!validatedInput.success) { +// return NextResponse.json({ error: 'Invalid input', details: validatedInput.error.flatten() }, { status: 400 }); +// } +// +// const output = await fetchGameHistoryLogic(validatedInput.data); +// return NextResponse.json(output); +// } diff --git a/src/app/api/ai/generate-improvement-tips/route.ts b/src/app/api/ai/generate-improvement-tips/route.ts new file mode 100644 index 0000000..f6c56f0 --- /dev/null +++ b/src/app/api/ai/generate-improvement-tips/route.ts @@ -0,0 +1,84 @@ +import { NextResponse } from 'next/server'; +import { + GenerateImprovementTipsInputSchema, + type GenerateImprovementTipsInput, + type GenerateImprovementTipsOutput +} from '@/lib/types/api-schemas'; + +// Improvement tip constants +const TIP_BLUNDER = "One or more blunders were identified. Focus on improving your tactical vision and calculation. Regularly solving puzzles can help. [Practice Puzzles on Lichess](https://lichess.org/training)"; +const TIP_MISTAKE = "Mistakes were made that significantly changed the evaluation. Review these moments carefully. Understanding why a mistake occurred is crucial for avoiding it in the future. [Analyze your games on Lichess](https://lichess.org/analysis)"; +const TIP_INACCURACY = "Inaccuracies can accumulate. Try to find the most precise moves, especially in critical positions. Deeper calculation or better positional understanding might be needed."; +const TIP_STOCKFISH = "Review the game with Stockfish to understand key moments and alternative lines. [Use Lichess Analysis Board](https://lichess.org/analysis)"; +const TIP_NO_KEYWORDS = "The analysis didn't pinpoint specific frequent errors based on keywords (blunder, mistake, inaccuracy). Consistent practice and reviewing your games are always beneficial. [Play a game on Lichess](https://lichess.org/play)"; +const TIP_NO_ANALYSIS = "No analysis data provided to generate tips. Please analyze a game first. [Play a game on Lichess](https://lichess.org/play)"; +const TIP_OPENINGS = "Consider studying common openings and their typical middlegame plans to get a better start. [Explore Openings on Lichess](https://lichess.org/opening)"; +const TIP_TACTICAL_OVERSIGHTS = "Double-check your moves for simple tactical oversights before committing, especially checking for undefended pieces or potential forks/pins."; +const TIP_GENERAL = "Keep practicing and learning! Every game is an opportunity to improve. [Play a game on Lichess](https://lichess.org/play)"; + +// Core logic function (renamed from generateRuleBasedTips) +async function generateTipsLogic(input: GenerateImprovementTipsInput): Promise { + const tips: string[] = []; + const analysisText = input.gameAnalysis.toLowerCase(); + + if (analysisText.includes("blunder")) { + tips.push(TIP_BLUNDER); + } + if (analysisText.includes("mistake")) { + tips.push(TIP_MISTAKE); + } + if (analysisText.includes("inaccuracy")) { + tips.push(TIP_INACCURACY); + } + + if (tips.length < 2 && analysisText.includes("stockfish")) { + tips.push(TIP_STOCKFISH); + } + + if (tips.length === 0 && !analysisText.includes("blunder") && !analysisText.includes("mistake") && !analysisText.includes("inaccuracy")) { + // Only add this if no specific errors were mentioned but analysis text is present + if (analysisText.length > 0) { // Check if analysisText is not empty + tips.push(TIP_NO_KEYWORDS); + } else { + tips.push(TIP_NO_ANALYSIS); + } + } + + // Add a general tip if space allows + if (tips.length < 3) { + tips.push(TIP_OPENINGS); + } + if (tips.length < 4 && (analysisText.includes("blunder") || analysisText.includes("mistake"))) { + tips.push(TIP_TACTICAL_OVERSIGHTS); + } + + // Ensure between 1 and 4 tips. + let finalTips = tips.slice(0, 4); + if (finalTips.length === 0) { // Ensure at least one tip, especially if input was empty + finalTips.push(TIP_GENERAL); + } + + return { tips: finalTips }; +} + +// Next.js API Route handler +export async function POST(request: Request) { + try { + const body = await request.json(); + const validatedInput = GenerateImprovementTipsInputSchema.safeParse(body); + + if (!validatedInput.success) { + return NextResponse.json({ error: 'Invalid input', details: validatedInput.error.flatten() }, { status: 400 }); + } + + const output = await generateTipsLogic(validatedInput.data); + return NextResponse.json(output); + + } catch (error) { + console.error('Error in generate-improvement-tips API route:', error); + if (error instanceof SyntaxError) { + return NextResponse.json({ error: 'Invalid JSON payload' }, { status: 400 }); + } + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/src/app/api/ai/training-bot-analysis/route.ts b/src/app/api/ai/training-bot-analysis/route.ts new file mode 100644 index 0000000..f17b216 --- /dev/null +++ b/src/app/api/ai/training-bot-analysis/route.ts @@ -0,0 +1,95 @@ +import { NextResponse } from 'next/server'; +import { + TrainingBotInputSchema, + type TrainingBotInput, + type TrainingBotOutput +} from '@/lib/types/api-schemas'; + +// Helper function to get Lichess FEN analysis (copied from original) +async function getLichessFenAnalysis(fen: string): Promise<{ moveUci?: string, evaluationCp?: number, mate?: number } | null> { + try { + const response = await fetch(`https://lichess.org/api/cloud-eval?fen=${encodeURIComponent(fen)}`, { + headers: { 'Accept': 'application/x-ndjson' } + }); + if (!response.ok) { + const errorBody = await response.text(); + console.error(`Lichess API error for FEN cloud-eval: ${response.status} ${response.statusText}`, errorBody); + return null; + } + const ndjson = await response.text(); + const lines = ndjson.trim().split('\n'); + for (const line of lines) { + if (line.trim()) { + const data = JSON.parse(line); + if (data.pvs && data.pvs.length > 0) { + const bestPv = data.pvs[0]; + return { + moveUci: bestPv.moves?.split(' ')[0], + evaluationCp: bestPv.cp, + mate: bestPv.mate, + }; + } + if (data.cp || data.mate) { + return { + moveUci: undefined, + evaluationCp: data.cp, + mate: data.mate, + }; + } + } + } + console.warn("Lichess FEN Analysis: No suitable PV or direct eval found in response.", ndjson); + return null; + } catch (error) { + console.error("Failed to fetch or parse Lichess FEN cloud evaluation:", error); + return null; + } +} + +// Core logic function (renamed from analyzeWithLichessStockfish) +async function trainingBotLogic(input: TrainingBotInput): Promise { + const analysis = await getLichessFenAnalysis(input.currentBoardState); + + if (!analysis) { + return { + suggestedMove: "N/A", + evaluation: 0, + explanation: "Could not get analysis from Lichess Stockfish.", + }; + } + + let finalEval = 0; + if (typeof analysis.evaluationCp === 'number') { + finalEval = analysis.evaluationCp; + } else if (typeof analysis.mate === 'number') { + finalEval = analysis.mate > 0 ? 10000 : -10000; // Convert mate to large CP value + } + + return { + suggestedMove: analysis.moveUci || "N/A (eval only)", + evaluation: finalEval, + explanation: formatTrainingBotExplanation(finalEval, analysis.mate), + }; +} + +// Next.js API Route handler +export async function POST(request: Request) { + try { + const body = await request.json(); + const validatedInput = TrainingBotInputSchema.safeParse(body); + + if (!validatedInput.success) { + return NextResponse.json({ error: 'Invalid input', details: validatedInput.error.flatten() }, { status: 400 }); + } + + const output = await trainingBotLogic(validatedInput.data); + return NextResponse.json(output); + + } catch (error) { + console.error('Error in training-bot-analysis API route:', error); + if (error instanceof SyntaxError) { + return NextResponse.json({ error: 'Invalid JSON payload' }, { status: 400 }); + } + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/src/components/train/training-bot-interface.tsx b/src/components/train/training-bot-interface.tsx index 95f52c0..321ef9c 100644 --- a/src/components/train/training-bot-interface.tsx +++ b/src/components/train/training-bot-interface.tsx @@ -14,7 +14,9 @@ import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from " import { ScrollArea } from '@/components/ui/scroll-area'; import { useToast } from "@/hooks/use-toast"; -import { analyzeGameAndSuggestMove, TrainingBotInput, TrainingBotOutput } from '@/ai/flows/training-bot-analysis'; +// Import types from the centralized api-schemas file +import { type TrainingBotInput, type TrainingBotOutput } from '@/lib/types/api-schemas'; +import { apiClient } from '@/lib/api-client'; import EvaluationBar from './evaluation-bar'; // Basic FEN validation: checks for 6 space-separated parts, and some common characters. @@ -92,7 +94,7 @@ export default function TrainingBotInterface({ onFenAnalyzed, initialFen }: Trai // gameHistory and moveNumber are less critical for single FEN analysis but can be kept if needed }; - const response = await analyzeGameAndSuggestMove(botInput); + const response = await apiClient.trainingBotAnalysis(botInput); addLogEntry({ fen: fenToAnalyze, diff --git a/src/lib/api-client.ts b/src/lib/api-client.ts new file mode 100644 index 0000000..2642f50 --- /dev/null +++ b/src/lib/api-client.ts @@ -0,0 +1,80 @@ +import { + type FetchGameHistoryInput, type FetchGameHistoryOutput, + type AnalyzeChessGameInput, type AnalyzeChessGameOutput, + type DeepAnalyzeGameMetricsInput, type DeepAnalyzeGameMetricsOutput, + type GenerateImprovementTipsInput, type GenerateImprovementTipsOutput, + type TrainingBotInput, type TrainingBotOutput +} from './types/api-schemas'; + +// Helper function for making API requests +async function post(endpoint: string, payload: TInput): Promise { + const response = await fetch(`/api/ai${endpoint}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(payload), + }); + + if (!response.ok) { + let errorData; + try { + errorData = await response.json(); + } catch (e) { + // If response is not JSON, use status text + errorData = { error: response.statusText, details: `Status: ${response.status}` }; + } + throw new Error(errorData?.error || `API request to ${endpoint} failed with status ${response.status}`); + } + return response.json() as Promise; +} + +export const apiClient = { + fetchGameHistory: (payload: FetchGameHistoryInput): Promise => { + return post('/fetch-game-history', payload); + }, + + analyzeChessGame: (payload: AnalyzeChessGameInput): Promise => { + return post('/analyze-chess-game', payload); + }, + + deepAnalyzeGameMetrics: (payload: DeepAnalyzeGameMetricsInput): Promise => { + // Note: The DeepAnalyzeGameMetricsInput requires a baseUrl. + // If this client is only used client-side, window.location.origin can be automatically added here, + // or it must be ensured that the caller provides it. + // For now, assuming payload includes baseUrl if called from client. + let modifiedPayload = payload; + if (typeof window !== 'undefined' && !payload.baseUrl) { + // Automatically set baseUrl if running in browser and not already set + modifiedPayload = { ...payload, baseUrl: window.location.origin }; + } else if (!payload.baseUrl) { + // This case should ideally not happen if called from server-side without explicit baseUrl. + // Or, if called server-side, baseUrl should point to the deployed app's URL. + console.warn("apiClient.deepAnalyzeGameMetrics: baseUrl is not set and not in a browser environment. Self-API calls might fail or use unexpected URLs if this API route is called by another server-side process without a full URL."); + } + return post('/deep-analyze-game-metrics', modifiedPayload); + }, + + generateImprovementTips: (payload: GenerateImprovementTipsInput): Promise => { + return post('/generate-improvement-tips', payload); + }, + + trainingBotAnalysis: (payload: TrainingBotInput): Promise => { + return post('/training-bot-analysis', payload); + }, +}; + +// Custom Error class for API errors, if more detailed error objects are preferred +export class ApiError extends Error { + status: number; + details?: any; + + constructor(message: string, status: number, details?: any) { + super(message); + this.name = 'ApiError'; + this.status = status; + this.details = details; + } +} + +// Example of a more robust post function using ApiError diff --git a/src/lib/types/api-schemas.ts b/src/lib/types/api-schemas.ts new file mode 100644 index 0000000..8132d68 --- /dev/null +++ b/src/lib/types/api-schemas.ts @@ -0,0 +1,84 @@ +import { z } from 'zod'; + +// Schemas from fetch-game-history +export const FetchGameHistoryInputSchema = z.object({ + platform: z.enum(["lichess", "chesscom", "chess24"]).describe('The chess platform (e.g., "lichess", "chesscom", "chess24").'), + username: z.string().describe('The username on the specified platform.'), + maxGames: z.number().optional().default(10).describe('Maximum number of games to fetch.'), +}); +export type FetchGameHistoryInput = z.infer; + +export const FetchGameHistoryOutputSchema = z.object({ + games: z.array(z.string().describe("A game in PGN format.")).describe("An array of game PGNs. Returns an empty array if no games are found or an error occurs.") +}); +export type FetchGameHistoryOutput = z.infer; + +// Schemas from analyze-chess-game +export const AnalyzeChessGameInputSchema = z.object({ + pgn: z.string().describe('The chess game in PGN format.'), +}); +export type AnalyzeChessGameInput = z.infer; + +export const AnalyzeChessGameOutputSchema = z.object({ + analysis: z.string().describe('A summary of the chess game analysis from Lichess Stockfish, highlighting significant evaluation swings (blunders, mistakes, inaccuracies).'), +}); +export type AnalyzeChessGameOutput = z.infer; + +// Schemas from deep-analyze-game-metrics +export const WeaknessSchema = z.object({ + name: z.string().describe("Concise name of the weakness (e.g., 'High Blunder Rate', 'Frequent Mistakes')."), + description: z.string().describe("Detailed explanation of the weakness based on aggregated Lichess analysis."), + severity: z.enum(["high", "medium", "low"]).describe("Assessed severity of the weakness."), + icon: z.enum(["AlertTriangle", "Puzzle", "BrainCircuit", "TrendingDown", "BarChart3", "Info"]).optional().describe("Suggested Lucide icon name."), + trainingSuggestion: z.object({ + text: z.string().describe("A concrete training suggestion."), + link: z.string().optional().describe("A relative path to a training page.") + }).describe("A concrete training suggestion to address the weakness.") +}); + +export const DeepAnalyzeGameMetricsInputSchema = z.object({ + gamePgns: z.array(z.string()).min(1).describe("An array of chess games in PGN format (at least one game required)."), + playerUsername: z.string().optional().describe("The username of the player whose games are being analyzed, for context."), + baseUrl: z.string().url().describe("Base URL of the current application, for self-API calls."), +}); +export type DeepAnalyzeGameMetricsInput = z.infer; + +export const DeepAnalyzeGameMetricsOutputSchema = z.object({ + overallSummary: z.string().describe("A brief overall summary based on aggregated Lichess game analyses."), + primaryWeaknesses: z.array(WeaknessSchema).min(0).max(2).describe("An array of 0 to 2 primary areas for improvement."), +}); +export type DeepAnalyzeGameMetricsOutput = z.infer; + +// Schemas from generate-improvement-tips +export const GenerateImprovementTipsInputSchema = z.object({ + gameAnalysis: z + .string() + .describe( + 'A textual summary of chess game analysis, expected to come from Lichess Stockfish (e.g., highlighting blunders, mistakes).' + ), +}); +export type GenerateImprovementTipsInput = z.infer; + +export const GenerateImprovementTipsOutputSchema = z.object({ + tips: z + .array(z.string()) + .describe( + 'An array of 1-4 plain-text improvement tips based on the game analysis summary.' + ), +}); +export type GenerateImprovementTipsOutput = z.infer; + +// Schemas from training-bot-analysis +export const TrainingBotInputSchema = z.object({ + gameHistory: z.string().optional().describe('The game history in PGN format of the user (optional for Lichess FEN analysis).'), + currentBoardState: z.string().describe('The current board state in FEN format.'), + moveNumber: z.number().optional().describe('The current move number in the game (optional).'), +}); +export type TrainingBotInput = z.infer; + +export const TrainingBotOutputSchema = z.object({ + suggestedMove: z.string().describe('The suggested move in UCI notation (e.g., e2e4).'), + evaluation: z.number().describe('The evaluation of the current board state from White\'s perspective (positive is good for white, in centipawns).'), + explanation: z.string().describe('Explanation of the suggestion source.'), +}); +export type TrainingBotOutput = z.infer;