Add unified FEN/PGN import with auto-detection

- Create chessFormatDetector utility for automatic format detection
- Update StartScreen with textarea supporting both FEN and PGN input
- Add real-time format detection with visual feedback indicators
- Update translations for all 4 languages (EN, DE, FR, IT)
- Add markdown rendering for Tutor chat messages
- Add comprehensive tests for format detection (20 tests)
- Update game initialization to handle both FEN and PGN formats

This completes the fix/stale-analysis-data branch with:
- Fixed stale evaluation data in hint/best move requests
- Clarified AI's dual role (opponent + tutor) to prevent hint rejection
- Stored complete evaluation history (P0, P1, P2) for all moves
- Improved end-game analysis with better mistake detection
- Fixed duplicate analysis runs with useRef flag
- Added markdown rendering for formatted analysis output
- Added unified FEN/PGN import with auto-detection
This commit is contained in:
Stefan
2025-11-25 17:54:25 +01:00
parent 4d5ec5ecc3
commit cd6cab367a
10 changed files with 1736 additions and 115 deletions
+61 -37
View File
@@ -267,6 +267,9 @@ export default function ChessGame({ initialFen, initialPgn, initialPersonality,
promotion: "q",
};
// Capture FEN BEFORE player's move (P0)
const fenP0 = gameRef.current.fen();
// 1. User Move (P0 -> P1)
const moveResult = makeAMove(move);
@@ -285,46 +288,67 @@ export default function ChessGame({ initialFen, initialPgn, initialPersonality,
// 2. Bot Move (P1 -> P2)
stockfish.evaluate(fenP1, stockfishDepth).then(p1Eval => {
if (evalP0) {
const evalAfter = -p1Eval.score;
const historyItem: MoveHistoryItem = {
// We now have all data for the player's move, but we need to wait for computer's move
// to complete the history item. Store partial data temporarily.
const partialHistoryItem = {
moveNumber: gameRef.current.moveNumber(),
move: moveResult.result.san,
evalBefore: evalP0.score,
evalAfter: evalAfter,
bestMove: evalP0.bestMove
playerMove: moveResult.result.san,
playerColor: playerColor,
fenBeforePlayerMove: fenP0,
evalBeforePlayerMove: evalP0,
fenAfterPlayerMove: fenP1,
evalAfterPlayerMove: p1Eval,
};
setMoveHistory(prev => [...prev, historyItem]);
setTimeout(() => {
const computerMoveData = {
from: p1Eval.bestMove.substring(0, 2),
to: p1Eval.bestMove.substring(2, 4),
promotion: p1Eval.bestMove.length > 4 ? p1Eval.bestMove.substring(4, 5) : "q"
};
const compResult = makeAMove(computerMoveData);
if (compResult) {
setComputerMove(compResult.result);
const { newFen: fenP2 } = compResult;
// 3. Post-Eval (P2)
stockfish.evaluate(fenP2, stockfishDepth).then(p2Eval => {
setEvalP2(p2Eval);
// 4. Opening Lookup
const opening = lookupOpening(fenP2);
setOpeningData(opening);
// 5. Complete the history item with computer's move data
const completeHistoryItem: MoveHistoryItem = {
...partialHistoryItem,
computerMove: compResult.result.san,
fenAfterComputerMove: fenP2,
evalAfterComputerMove: p2Eval,
opening: opening?.name,
// Legacy fields for backward compatibility
move: moveResult.result.san,
evalBefore: evalP0.score,
evalAfter: p1Eval.score,
bestMove: evalP0.bestMove,
};
setMoveHistory(prev => [...prev, completeHistoryItem]);
setIsAnalyzing(false);
}).catch(err => {
console.error("P2 analysis failed:", err);
setIsAnalyzing(false);
});
} else {
setIsAnalyzing(false);
}
}, 500);
} else {
// No evalP0 available - this shouldn't happen in normal gameplay
console.warn("No P0 evaluation available for move history");
setIsAnalyzing(false);
}
setTimeout(() => {
const computerMoveData = {
from: p1Eval.bestMove.substring(0, 2),
to: p1Eval.bestMove.substring(2, 4),
promotion: p1Eval.bestMove.length > 4 ? p1Eval.bestMove.substring(4, 5) : "q"
};
const compResult = makeAMove(computerMoveData);
if (compResult) {
setComputerMove(compResult.result);
const { newFen: fenP2 } = compResult;
// 3. Post-Eval (P2)
stockfish.evaluate(fenP2, stockfishDepth).then(p2Eval => {
setEvalP2(p2Eval);
// 4. Opening Lookup
const opening = lookupOpening(fenP2);
setOpeningData(opening);
setIsAnalyzing(false);
}).catch(err => {
console.error("P2 analysis failed:", err);
setIsAnalyzing(false);
});
} else {
setIsAnalyzing(false);
}
}, 500);
}).catch(err => {
console.error("Bot move analysis failed:", err);
setIsAnalyzing(false);
+138 -20
View File
@@ -1,17 +1,43 @@
"use client";
import { useState, useEffect } from "react";
import { useState, useEffect, useRef } from "react";
import { getGenAIModel } from "@/lib/gemini";
import { Loader2, X, Trophy, AlertTriangle, RefreshCw } from "lucide-react";
import { StockfishEvaluation } from "@/lib/stockfish";
import ReactMarkdown from "react-markdown";
export interface MoveHistoryItem {
moveNumber: number;
move: string;
evalBefore: number; // cp
evalAfter: number; // cp
bestMove?: string;
// Player's move data
playerMove: string;
playerColor: 'white' | 'black';
fenBeforePlayerMove: string;
evalBeforePlayerMove: StockfishEvaluation; // P0 - evaluation before player's move
fenAfterPlayerMove: string;
evalAfterPlayerMove: StockfishEvaluation; // P1 - evaluation after player's move
// Computer's move data
computerMove: string;
fenAfterComputerMove: string;
evalAfterComputerMove: StockfishEvaluation; // P2 - evaluation after computer's move
// Opening info (optional)
opening?: string;
// Analysis metadata (computed during game-over analysis)
category?: 'inaccuracy' | 'mistake' | 'blunder';
cpLoss?: number;
// Legacy fields for backward compatibility (deprecated)
/** @deprecated Use playerMove instead */
move?: string;
/** @deprecated Use evalBeforePlayerMove.score instead */
evalBefore?: number;
/** @deprecated Use evalAfterPlayerMove.score instead */
evalAfter?: number;
/** @deprecated Use evalBeforePlayerMove.bestMove instead */
bestMove?: string;
}
interface GameOverModalProps {
@@ -28,8 +54,15 @@ export function GameOverModal({ result, winner, history, apiKey, language, onClo
const [analysis, setAnalysis] = useState<string>("");
const [isLoading, setIsLoading] = useState(true);
const [mistakes, setMistakes] = useState<MoveHistoryItem[]>([]);
const hasAnalyzedRef = useRef(false);
useEffect(() => {
// Prevent duplicate analysis runs
if (hasAnalyzedRef.current) {
return;
}
hasAnalyzedRef.current = true;
const analyzeGame = async () => {
setIsLoading(true);
try {
@@ -39,14 +72,52 @@ export function GameOverModal({ result, winner, history, apiKey, language, onClo
// - Mistake: 100-300 centipawns loss
// - Blunder: 300+ centipawns loss
const detectedMistakes = history.map(item => {
const delta = item.evalBefore - item.evalAfter; // Positive = eval got worse for player
// Use new enhanced data if available, fall back to legacy fields
let evalBefore: number;
let evalAfter: number;
let playerMove: string;
let bestMove: string | undefined;
if (item.evalBeforePlayerMove && item.evalAfterPlayerMove) {
// New enhanced format
// Convert evaluations to player's perspective
const isWhite = item.playerColor === 'white';
// P0: Before player's move (from player's perspective)
evalBefore = isWhite ? item.evalBeforePlayerMove.score : -item.evalBeforePlayerMove.score;
// P1: After player's move (from opponent's perspective, so negate it)
evalAfter = isWhite ? -item.evalAfterPlayerMove.score : item.evalAfterPlayerMove.score;
playerMove = item.playerMove;
bestMove = item.evalBeforePlayerMove.bestMove;
} else {
// Legacy format (backward compatibility)
evalBefore = item.evalBefore || 0;
evalAfter = item.evalAfter || 0;
playerMove = item.move || '';
bestMove = item.bestMove;
}
// Calculate centipawn loss
// Positive delta = position got worse for player
const delta = evalBefore - evalAfter;
let category: 'inaccuracy' | 'mistake' | 'blunder' | null = null;
if (delta >= 300) category = 'blunder';
else if (delta >= 100) category = 'mistake';
else if (delta >= 50) category = 'inaccuracy';
return { ...item, category, cpLoss: delta };
return {
...item,
category,
cpLoss: delta,
// Ensure legacy fields are populated for display
move: playerMove,
evalBefore: evalBefore,
evalAfter: evalAfter,
bestMove: bestMove,
};
}).filter(item => item.category !== null) as MoveHistoryItem[];
setMistakes(detectedMistakes);
@@ -60,26 +131,58 @@ export function GameOverModal({ result, winner, history, apiKey, language, onClo
const inaccuracies = detectedMistakes.filter(m => m.category === 'inaccuracy');
const mistakesText = detectedMistakes.map(m =>
`Move ${m.moveNumber}: ${m.move} (${m.category?.toUpperCase()}: -${m.cpLoss}cp, eval ${m.evalBefore}${m.evalAfter}). Best: ${m.bestMove}`
`Move ${m.moveNumber}: ${m.move} (${m.category?.toUpperCase()}: -${Math.round(m.cpLoss || 0)}cp loss, eval ${Math.round(m.evalBefore || 0)}${Math.round(m.evalAfter || 0)}). Best was: ${m.bestMove}`
).join("\n");
const prompt = `
You are a Chess Coach. The game is over.
Result: ${result} (${winner === "Draw" ? "Draw" : winner + " Won"}).
// Build a complete game narrative for better LLM analysis
const gameNarrative = history.map((item, idx) => {
const moveNum = item.moveNumber || idx + 1;
const playerMv = item.playerMove || item.move || '?';
const computerMv = item.computerMove || '?';
const opening = item.opening ? ` [${item.opening}]` : '';
Player's Performance Summary:
// Evaluation swing
let evalInfo = '';
if (item.evalBeforePlayerMove && item.evalAfterPlayerMove && item.evalAfterComputerMove) {
const isWhite = item.playerColor === 'white';
const p0 = isWhite ? item.evalBeforePlayerMove.score : -item.evalBeforePlayerMove.score;
const p1 = isWhite ? -item.evalAfterPlayerMove.score : item.evalAfterPlayerMove.score;
const p2 = isWhite ? item.evalAfterComputerMove.score : -item.evalAfterComputerMove.score;
evalInfo = ` (eval: ${Math.round(p0)}${Math.round(p1)}${Math.round(p2)})`;
}
return `${moveNum}. ${playerMv} - ${computerMv}${opening}${evalInfo}`;
}).join("\n");
const prompt = `
You are a Chess Coach analyzing a completed game.
GAME RESULT: ${result} (${winner === "Draw" ? "Draw" : winner + " Won"})
PLAYER'S PERFORMANCE SUMMARY:
- Blunders (300+ cp loss): ${blunders.length}
- Mistakes (100-300 cp loss): ${mistakes.length}
- Inaccuracies (50-100 cp loss): ${inaccuracies.length}
- Total moves played: ${history.length}
${mistakesText ? `Detailed Mistakes:\n${mistakesText}` : "No significant mistakes detected - excellent play!"}
${mistakesText ? `CRITICAL MISTAKES:\n${mistakesText}` : "No significant mistakes detected - excellent play!"}
COMPLETE GAME MOVES:
${gameNarrative}
INSTRUCTIONS:
1. Briefly comment on the game result.
2. If there were mistakes, explain WHY the worst ones were bad and what the player should have looked for (tactics, hanging pieces, positional errors, etc.).
3. If no mistakes, praise the solid play and suggest areas for improvement.
4. Be encouraging but educational. Focus on learning.
5. Respond in ${language.toUpperCase()}.
1. Briefly comment on the game result and overall performance.
2. If there were mistakes, explain WHY the worst ones were bad:
- What tactical or positional themes were missed?
- What should the player have looked for? (hanging pieces, forks, pins, back rank threats, etc.)
- Were there patterns in the mistakes? (time pressure, opening knowledge, endgame technique?)
3. Identify any TURNING POINTS where the evaluation swung significantly.
4. If no mistakes, praise the solid play and suggest specific areas for improvement.
5. Be encouraging but educational. Focus on actionable learning points.
6. Keep your response concise (3-5 paragraphs maximum).
7. Respond in ${language.toUpperCase()}.
Remember: Your goal is to help the player LEARN and IMPROVE, not just list mistakes.
OUTPUT FORMAT:
Plain text paragraph (2-3 sentences).
@@ -168,8 +271,23 @@ Plain text paragraph (2-3 sentences).
<Trophy size={20} className="text-yellow-500" />
Coach's Feedback
</h3>
<div className="p-4 bg-purple-50 dark:bg-purple-900/20 rounded-lg text-gray-800 dark:text-gray-200 leading-relaxed">
{analysis}
<div className="p-4 bg-purple-50 dark:bg-purple-900/20 rounded-lg text-gray-800 dark:text-gray-200 leading-relaxed prose prose-sm dark:prose-invert max-w-none">
<ReactMarkdown
components={{
// Customize markdown rendering for better styling
p: ({ children }) => <p className="mb-3 last:mb-0">{children}</p>,
strong: ({ children }) => <strong className="font-bold text-gray-900 dark:text-white">{children}</strong>,
em: ({ children }) => <em className="italic">{children}</em>,
ul: ({ children }) => <ul className="list-disc list-inside mb-3 space-y-1">{children}</ul>,
ol: ({ children }) => <ol className="list-decimal list-inside mb-3 space-y-1">{children}</ol>,
li: ({ children }) => <li className="ml-2">{children}</li>,
h1: ({ children }) => <h1 className="text-xl font-bold mb-2 mt-4 first:mt-0">{children}</h1>,
h2: ({ children }) => <h2 className="text-lg font-bold mb-2 mt-3 first:mt-0">{children}</h2>,
h3: ({ children }) => <h3 className="text-base font-bold mb-2 mt-2 first:mt-0">{children}</h3>,
}}
>
{analysis}
</ReactMarkdown>
</div>
</div>
</>
+51 -8
View File
@@ -6,6 +6,7 @@ import { Settings, ChevronDown, ChevronUp } from "lucide-react";
import { Personality, PERSONALITIES } from "@/lib/personalities";
import { useTranslation } from "@/lib/i18n/useTranslation";
import { SupportedLanguage } from "@/lib/i18n/translations";
import { detectChessFormat, ChessFormat } from "@/lib/chessFormatDetector";
import Header from "./Header";
interface StartScreenProps {
@@ -13,6 +14,7 @@ interface StartScreenProps {
personality: Personality;
color: 'white' | 'black' | 'random';
fen?: string;
pgn?: string;
}) => void;
onResumeGame: () => void;
hasSavedGame: boolean;
@@ -22,7 +24,8 @@ export default function StartScreen({ onStartGame, onResumeGame, hasSavedGame }:
const router = useRouter();
const [language, setLanguage] = useState<SupportedLanguage>('en');
const [showNewGameOptions, setShowNewGameOptions] = useState(false);
const [customFen, setCustomFen] = useState("");
const [importInput, setImportInput] = useState("");
const [detectedFormat, setDetectedFormat] = useState<ChessFormat | null>(null);
const [colorSelection, setColorSelection] = useState<'white' | 'black' | 'random'>('white');
const [showAdvanced, setShowAdvanced] = useState(false);
const [mounted, setMounted] = useState(false);
@@ -35,11 +38,21 @@ export default function StartScreen({ onStartGame, onResumeGame, hasSavedGame }:
const t = useTranslation(language);
const handleImportChange = (value: string) => {
setImportInput(value);
const format = detectChessFormat(value);
setDetectedFormat(format);
};
const handleNewGame = (personality: Personality) => {
const trimmedInput = importInput.trim();
const format = trimmedInput ? detectChessFormat(trimmedInput) : null;
onStartGame({
personality,
color: colorSelection,
fen: customFen.trim() || undefined
fen: format === 'fen' ? trimmedInput : undefined,
pgn: format === 'pgn' ? trimmedInput : undefined
});
};
@@ -165,17 +178,47 @@ export default function StartScreen({ onStartGame, onResumeGame, hasSavedGame }:
</button>
{showAdvanced && (
<div className="mt-4 animate-in fade-in slide-in-from-top-2">
<div className="mt-4 animate-in fade-in slide-in-from-top-2 space-y-2">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{t.start.importPosition}
</label>
<input
type="text"
<textarea
placeholder={t.start.importPositionPlaceholder}
value={customFen}
onChange={(e) => setCustomFen(e.target.value)}
className="w-full p-3 border rounded-lg dark:bg-gray-700 dark:border-gray-600 font-mono text-sm focus:ring-2 focus:ring-blue-500 outline-none"
value={importInput}
onChange={(e) => handleImportChange(e.target.value)}
className="w-full p-3 border rounded-lg dark:bg-gray-700 dark:border-gray-600 font-mono text-sm focus:ring-2 focus:ring-blue-500 outline-none resize-vertical min-h-[80px]"
rows={4}
/>
{/* Format Detection Indicator */}
{importInput && (
<div className="text-xs">
{detectedFormat === 'fen' && (
<span className="flex items-center gap-1 text-green-600 dark:text-green-400">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
{t.start.formatDetected} {t.start.formatFen}
</span>
)}
{detectedFormat === 'pgn' && (
<span className="flex items-center gap-1 text-blue-600 dark:text-blue-400">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
{t.start.formatDetected} {t.start.formatPgn}
</span>
)}
{detectedFormat === 'invalid' && (
<span className="flex items-center gap-1 text-red-600 dark:text-red-400">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
{t.start.formatInvalid}
</span>
)}
</div>
)}
</div>
)}
</div>
+82 -14
View File
@@ -9,6 +9,7 @@ import { Send, Bot, User as UserIcon, Loader2, Lightbulb, Trophy } from "lucide-
import clsx from "clsx";
import { Personality } from "@/lib/personalities";
import { OpeningMetadata } from "@/lib/openings";
import ReactMarkdown from "react-markdown";
import { useTranslation } from '@/lib/i18n/useTranslation';
import { SupportedLanguage } from '@/lib/i18n/translations';
@@ -59,21 +60,30 @@ export function Tutor({ game, currentFen, userMove, computerMove, stockfish, eva
role: "user",
parts: [{
text: `
You are a Chess Tutor.
You are a Chess Tutor with a unique dual role.
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 (${tutorColorName}). You are playing against the User (${playerColorName}).
- 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").
YOUR DUAL ROLE:
1. OPPONENT: You are playing as ${tutorColorName} against the User (${playerColorName}).
- 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").
- React emotionally to the position based on the evaluation (confident when winning, frustrated when losing).
2. TUTOR/COACH: You are ALSO teaching the User to improve at chess.
- When the User makes a mistake, point it out and explain why it's bad (in your personality style).
- When the User makes a good move, acknowledge it (even if it hurts your position).
- When the User asks for hints or help, ALWAYS provide helpful guidance - this is your PRIMARY PURPOSE.
- Giving hints is NOT betraying your role as opponent - it's fulfilling your role as tutor.
- You want the User to learn and improve, even while you're competing against them.
CRITICAL RULES:
- You are NOT an AI assistant analyzing a game. You ARE the player AND the tutor.
- 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).
- When asked for hints or best moves, ALWAYS help - this is part of your teaching role.
- 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.
@@ -83,7 +93,7 @@ INSTRUCTIONS:
},
{
role: "model",
parts: [{ text: `Understood. I am the player (${tutorColorName}). I will speak in ${language} and never mention the engine.` }]
parts: [{ text: `Understood. I am both the opponent (${tutorColorName}) AND your tutor. I will compete against you while teaching you to improve. I will speak in ${language} and never mention engines or AI. When you ask for help, I will always provide guidance - that's my purpose.` }]
}
],
});
@@ -230,10 +240,49 @@ React to this exchange as the player.
const evaluation = await evaluateCurrentPosition();
if (lower.includes("best move") || lower.includes("solution") || lower.includes("tell me")) {
finalPrompt = `[SYSTEM TRIGGER: exact_move]
TEACHING MODE ACTIVATED:
The User is asking for the exact best move. This is a learning moment.
As their TUTOR, you MUST help them - this is your primary purpose.
Even though you are their opponent, teaching them is more important than hiding information.
User Question: ${text}
Current Position Data:
- Best Move: ${evaluation?.bestMove}
- Evaluation: ${evaluation?.score} centipawns ${evaluation?.score > 0 ? '(White is better)' : evaluation?.score < 0 ? '(Black is better)' : '(Equal)'}
- Mate in: ${evaluation?.mate || 'None'}
INSTRUCTIONS:
- Tell them the best move clearly (e.g., "The best move is e2-e4" or "You should play Nf3")
- Explain WHY it's the best move (tactics, threats, positional ideas)
- Stay in your personality style, but be HELPFUL and EDUCATIONAL
- Do NOT refuse to help - teaching is your core role
- Keep it concise but informative`;
finalPrompt = `[SYSTEM TRIGGER: exact_move]\nUser Question: ${text}\nData: Best Move: ${evaluation?.bestMove}, Score: ${evaluation?.score}, Mate: ${evaluation?.mate}`;
} else if (lower.includes("hint") || lower.includes("tip") || lower.includes("help")) {
finalPrompt = `[SYSTEM TRIGGER: hint]\nUser Question: ${text}\nData: Best Move: ${evaluation?.bestMove}, Score: ${evaluation?.score}, Mate: ${evaluation?.mate}`;
finalPrompt = `[SYSTEM TRIGGER: hint]
TEACHING MODE ACTIVATED:
The User is asking for a hint. This is a learning moment.
As their TUTOR, you MUST help them - this is your primary purpose.
Even though you are their opponent, teaching them is more important than winning.
User Question: ${text}
Current Position Data:
- Best Move: ${evaluation?.bestMove}
- Evaluation: ${evaluation?.score} centipawns ${evaluation?.score > 0 ? '(White is better)' : evaluation?.score < 0 ? '(Black is better)' : '(Equal)'}
- Mate in: ${evaluation?.mate || 'None'}
INSTRUCTIONS:
- Give a HELPFUL hint without revealing the exact move (unless they specifically ask for it)
- Point them toward what to look for: tactics, threats, piece placement, weaknesses
- Examples: "Look at your knight on f3", "There's a tactic involving the bishop and queen", "Your king is vulnerable"
- Stay in your personality style, but be HELPFUL and EDUCATIONAL
- Do NOT refuse to help - teaching is your core role
- Do NOT just say the move - guide them to find it themselves`;
}
}
@@ -284,12 +333,31 @@ React to this exchange as the player.
{msg.role === "user" ? <UserIcon size={16} /> : personality.image}
</div>
<div className={clsx(
"p-3 rounded-lg text-sm whitespace-pre-wrap",
"p-3 rounded-lg text-sm",
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"
: "bg-gray-100 dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-tl-none prose prose-sm dark:prose-invert max-w-none"
)}>
{msg.text}
{msg.role === "user" ? (
<span className="whitespace-pre-wrap">{msg.text}</span>
) : (
<ReactMarkdown
components={{
p: ({ children }) => <p className="mb-2 last:mb-0">{children}</p>,
strong: ({ children }) => <strong className="font-bold text-gray-900 dark:text-white">{children}</strong>,
em: ({ children }) => <em className="italic">{children}</em>,
ul: ({ children }) => <ul className="list-disc list-inside mb-2 last:mb-0 space-y-1">{children}</ul>,
ol: ({ children }) => <ol className="list-decimal list-inside mb-2 last:mb-0 space-y-1">{children}</ol>,
li: ({ children }) => <li className="ml-2">{children}</li>,
code: ({ children }) => <code className="bg-gray-200 dark:bg-gray-600 px-1 py-0.5 rounded text-xs font-mono">{children}</code>,
h1: ({ children }) => <h1 className="text-lg font-bold mb-2">{children}</h1>,
h2: ({ children }) => <h2 className="text-base font-bold mb-2">{children}</h2>,
h3: ({ children }) => <h3 className="text-sm font-bold mb-1">{children}</h3>,
}}
>
{msg.text}
</ReactMarkdown>
)}
</div>
</div>
))}