working app

This commit is contained in:
Stefan
2025-11-23 10:51:00 +01:00
parent f6480a43c6
commit 30f1e9e3a5
22 changed files with 106271 additions and 63 deletions
+309
View File
@@ -0,0 +1,309 @@
"use client";
import { useState, useEffect, useRef } from "react";
import { Stockfish, StockfishEvaluation } from "@/lib/stockfish";
import { Move } from "chess.js";
import { getGenAIModel } from "@/lib/gemini";
import { ChatSession } from "@google/generative-ai";
import { Send, Bot, User as UserIcon, Loader2, Lightbulb, Trophy } from "lucide-react";
import clsx from "clsx";
import { Personality } from "@/lib/personalities";
import { OpeningMetadata } from "@/lib/openings";
interface TutorProps {
currentFen: string;
userMove: Move | null;
computerMove: Move | null;
stockfish: Stockfish | null;
evalP0: StockfishEvaluation | null;
evalP2: StockfishEvaluation | null;
openingData: OpeningMetadata | null;
onAnalysisComplete: () => void;
apiKey: string | null;
personality: Personality;
language: 'en' | 'de' | 'fr' | 'it';
}
interface Message {
role: "user" | "model";
text: string;
timestamp: number;
}
export function Tutor({ currentFen, userMove, computerMove, stockfish, evalP0, evalP2, openingData, onAnalysisComplete, apiKey, personality, language }: TutorProps) {
const [messages, setMessages] = useState<Message[]>([
{ role: "model", text: "Hello! I am " + personality.name + ". " + personality.description + " Let's play!", timestamp: Date.now() }
]);
const [input, setInput] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [chatSession, setChatSession] = useState<ChatSession | null>(null);
const messagesEndRef = useRef<HTMLDivElement>(null);
// Initialize chat session with Personality System Prompt
useEffect(() => {
if (apiKey) {
const model = getGenAIModel(apiKey, "gemini-2.5-flash");
const session = model.startChat({
history: [
{
role: "user",
parts: [{
text: `
You are a Chess Tutor.
You must strictly follow the personality defined below.
Do NOT invent moves or evaluations. Use the provided JSON data.
PERSONALITY:
${personality.systemPrompt}
INSTRUCTIONS:
- You are the opponent (Black). You are playing against the User (White).
- You are NOT an AI assistant analyzing a game. You ARE the player.
- Refer to the moves as YOUR moves ("I played e5", "My response was...").
- Refer to the evaluation as YOUR thoughts/assessment ("I think I'm winning", "I missed that").
- NEVER mention "Stockfish", "engine", "computer", "machine", or "AI".
- If the evaluation says you are winning, be confident/arrogant (depending on personality).
- If you are losing, be frustrated/worried (depending on personality).
- Maintain a natural conversation flow. Do NOT be repetitive.
- Do NOT use the same catchphrases in every single message. Variety is key.
- Be concise but engaging.
- You MUST respond in the following language: ${language.toUpperCase()}.
- Translate your personality style into this language.
` }]
},
{
role: "model",
parts: [{ text: `Understood. I am the player (Black). I will speak in ${language} and never mention the engine.` }]
}
],
});
setChatSession(session);
// Reset messages on language/personality change
setMessages([{ role: "model", text: `Hello! I am ${personality.name}. Let's play!`, timestamp: Date.now() }]);
}
}, [apiKey, personality, language]);
// Scroll to bottom
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages]);
const lastAnalyzedMoveRef = useRef<string | null>(null);
// Stage 1: Automatic Reaction after COMPUTER Move (so we see the full exchange)
useEffect(() => {
if (!userMove || !computerMove || !evalP0 || !evalP2 || !chatSession) return;
// Create a unique key for this exchange
const exchangeKey = `${userMove.lan}-${computerMove.lan}`;
// Prevent double analysis
if (lastAnalyzedMoveRef.current === exchangeKey) return;
lastAnalyzedMoveRef.current = exchangeKey;
// We trigger this when computerMove changes (meaning the exchange is complete)
const analyzeExchange = async () => {
setIsLoading(true);
try {
// Calculate Evaluation Change (Delta)
// evalP0: Before User Move (White's perspective)
// evalP2: After Bot Move (White's perspective)
// Delta = evalP2 - evalP0
const preScore = evalP0.score;
const postScore = evalP2.score;
const preMate = evalP0.mate;
const postMate = evalP2.mate;
const delta = postScore - preScore;
// Check for significant change
let isSignificant = false;
if (preMate !== null || postMate !== null) {
isSignificant = true; // Any mate involvement is significant
} else if (Math.abs(delta) >= 50) {
isSignificant = true; // > 0.5 pawn change
}
let evalInstruction = "";
if (isSignificant) {
evalInstruction = `The evaluation changed SIGNIFICANTLY (Delta: ${delta} cp). You MUST comment on this shift in power and what caused it.`;
} else {
evalInstruction = "The evaluation change is MINOR/INSIGNIFICANT. Do NOT mention the score, 'advantage', or who is winning. Focus ONLY on the strategic purpose of the moves.";
}
// Opening Instruction
let openingInstruction = "";
if (openingData) {
openingInstruction = `
OPENING IDENTIFIED: ${openingData.name} (${openingData.eco}).
You MUST mention the opening name.
You can use this metadata to explain the position:
- Strengths (White): ${openingData.meta?.strengths_white?.join(", ")}
- Weaknesses (White): ${openingData.meta?.weaknesses_white?.join(", ")}
- Strengths (Black): ${openingData.meta?.strengths_black?.join(", ")}
- Weaknesses (Black): ${openingData.meta?.weaknesses_black?.join(", ")}
`;
} else {
// openingInstruction = "NO opening identified. Do NOT invent an opening name. Do NOT mention openings.";
// Relaxed instruction to allow general commentary if no specific opening is found, but still forbid inventing names.
openingInstruction = "NO specific opening identified from database. Do NOT invent an opening name. Focus on the position.";
}
const prompt = `
[SYSTEM TRIGGER: move_exchange]
User (White) Move: ${userMove.san}
My (Black) Reply: ${computerMove.san}
My Internal Thoughts (Data):
- Pre-Eval (Before User Move): ${preScore} cp
- Post-Eval (After My Reply): ${postScore} cp
- Delta: ${delta} cp
INSTRUCTIONS:
1. ${evalInstruction}
2. ${openingInstruction}
3. Respond in ${language}.
React to this exchange as the player.
`;
await sendMessageToChat(prompt, true);
} catch (e) {
console.error(e);
} finally {
setIsLoading(false);
onAnalysisComplete();
}
};
analyzeExchange();
}, [computerMove, chatSession, evalP0, evalP2, userMove, onAnalysisComplete, openingData, language]);
const sendMessageToChat = async (text: string, isSystemMessage: boolean = false) => {
if (!chatSession) return;
if (!isSystemMessage) {
setMessages(prev => [...prev, { role: "user", text, timestamp: Date.now() }]);
}
setIsLoading(true);
try {
// Determine mode based on user text if it's not a system message
let finalPrompt = text;
if (!isSystemMessage) {
const lower = text.toLowerCase();
if (lower.includes("best move") || lower.includes("solution") || lower.includes("tell me")) {
finalPrompt = `[SYSTEM TRIGGER: exact_move]\nUser Question: ${text}\nData: Post-Eval Best Move: ${evalP2?.bestMove}`;
} else if (lower.includes("hint") || lower.includes("tip") || lower.includes("help")) {
finalPrompt = `[SYSTEM TRIGGER: hint]\nUser Question: ${text}`;
}
}
const result = await chatSession.sendMessage(finalPrompt);
const response = await result.response;
const textResponse = response.text();
setMessages(prev => [...prev, { role: "model", text: textResponse, timestamp: Date.now() }]);
} catch (error) {
console.error("Chat Error:", error);
setMessages(prev => [...prev, { role: "model", text: "Sorry, I encountered an error.", timestamp: Date.now() }]);
} finally {
setIsLoading(false);
}
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!input.trim() || !chatSession) return;
sendMessageToChat(input);
setInput("");
};
if (!apiKey) return null;
return (
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 h-[600px] flex flex-col">
{/* Header */}
<div className="p-4 border-b border-gray-200 dark:border-gray-700 flex items-center gap-3 bg-gray-50 dark:bg-gray-900 rounded-t-lg">
<div className="text-2xl">{personality.image}</div>
<div>
<h2 className="font-bold text-gray-900 dark:text-white">{personality.name}</h2>
<p className="text-xs text-gray-500 dark:text-gray-400">AI Coach ({language.toUpperCase()})</p>
</div>
</div>
{/* Messages Area */}
<div className="flex-1 overflow-y-auto p-4 space-y-4">
{messages.map((msg, idx) => (
<div key={idx} className={clsx(
"flex gap-3 max-w-[85%]",
msg.role === "user" ? "ml-auto flex-row-reverse" : ""
)}>
<div className={clsx(
"w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0 text-sm",
msg.role === "user" ? "bg-blue-600 text-white" : "bg-gray-200 dark:bg-gray-700"
)}>
{msg.role === "user" ? <UserIcon size={16} /> : personality.image}
</div>
<div className={clsx(
"p-3 rounded-lg text-sm whitespace-pre-wrap",
msg.role === "user"
? "bg-blue-600 text-white rounded-tr-none"
: "bg-gray-100 dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-tl-none"
)}>
{msg.text}
</div>
</div>
))}
{isLoading && (
<div className="flex gap-3">
<div className="w-8 h-8 rounded-full bg-gray-200 dark:bg-gray-700 flex items-center justify-center flex-shrink-0">
{personality.image}
</div>
<div className="bg-gray-100 dark:bg-gray-700 p-3 rounded-lg rounded-tl-none flex items-center">
<Loader2 className="animate-spin text-gray-500" size={16} />
</div>
</div>
)}
<div ref={messagesEndRef} />
</div>
{/* Quick Actions */}
<div className="px-4 py-2 flex gap-2 overflow-x-auto">
<button
onClick={() => sendMessageToChat("Give me a hint")}
className="flex items-center gap-1 px-3 py-1 text-xs bg-yellow-100 text-yellow-800 rounded-full hover:bg-yellow-200 dark:bg-yellow-900 dark:text-yellow-200"
>
<Lightbulb size={12} /> Hint
</button>
<button
onClick={() => sendMessageToChat("What is the best move?")}
className="flex items-center gap-1 px-3 py-1 text-xs bg-green-100 text-green-800 rounded-full hover:bg-green-200 dark:bg-green-900 dark:text-green-200"
>
<Trophy size={12} /> Best Move
</button>
</div>
{/* Input Area */}
<form onSubmit={handleSubmit} className="p-4 border-t border-gray-200 dark:border-gray-700 flex gap-2">
<input
type="text"
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="Ask your coach..."
className="flex-1 p-2 border rounded-lg dark:bg-gray-700 dark:border-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500"
disabled={isLoading}
/>
<button
type="submit"
disabled={isLoading || !input.trim()}
className="p-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
<Send size={20} />
</button>
</form>
</div>
);
}