feat: merge chess_tutor + OpenRouter abstraction
Build and publish Docker image to GHCR / build-and-push (push) Has been cancelled
Build and publish Docker image to GHCR / build-and-push (push) Has been cancelled
- Merged stefan-kp/chess_tutor into chess-project with full feature set - Preserved Hermes agent files (agents/, .gitea/, AGENTS.md) - Added OpenRouter abstraction layer (src/lib/openrouter.ts) - Created ModelSelector dropdown component with 9 models - Updated API routes (chat + opening explanation) to use OpenRouter - Updated useTutorChat to route through OpenRouter API - Updated onboarding and API key input for OpenRouter - Created tasks.md with sprint 1 plan - Updated README with new architecture details
This commit is contained in:
+8
-1
@@ -1,5 +1,12 @@
|
|||||||
# Debug Mode
|
# Debug Mode
|
||||||
# Set to 'true' to enable debug mode which shows LLM prompts and responses
|
# Set to 'true' to enable debug mode which shows LLM prompts and responses
|
||||||
# This is useful for debugging AI behavior
|
|
||||||
NEXT_PUBLIC_DEBUG=false
|
NEXT_PUBLIC_DEBUG=false
|
||||||
|
|
||||||
|
# OpenRouter API Key (optional — users can also enter in the UI)
|
||||||
|
# Get your key at https://openrouter.ai/keys
|
||||||
|
# OPENROUTER_API_KEY=sk-or-v1-...
|
||||||
|
|
||||||
|
# Default model (user can change in the UI)
|
||||||
|
# Options: google/gemini-2.5-flash, google/gemini-2.5-pro, anthropic/claude-sonnet-4,
|
||||||
|
# openai/gpt-4o, x-ai/grok-3, deepseek/deepseek-chat, meta-llama/llama-3.1-70b
|
||||||
|
NEXT_PUBLIC_DEFAULT_MODEL=google/gemini-2.5-flash
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
---
|
||||||
|
name: Feature Request
|
||||||
|
about: Suggest a new feature for this project
|
||||||
|
title: '[FEATURE] '
|
||||||
|
labels: feature
|
||||||
|
---
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
## Motivation
|
||||||
|
|
||||||
|
## Proposed Solution
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- [ ]
|
||||||
|
- [ ]
|
||||||
|
- [ ]
|
||||||
|
|
||||||
|
## Related Planning
|
||||||
|
|
||||||
|
Reference any related issues or plans from the `core-planning` repository.
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
## Summary
|
||||||
|
|
||||||
|
## Type of Change
|
||||||
|
- [ ] Bug fix
|
||||||
|
- [ ] New feature
|
||||||
|
- [ ] Refactoring
|
||||||
|
- [ ] Documentation
|
||||||
|
- [ ] Other:
|
||||||
|
|
||||||
|
## Related Task / Issue
|
||||||
|
|
||||||
|
Reference the task from `/tasks` or a planning issue from `core-planning`.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
- [ ] Unit tests added/updated
|
||||||
|
- [ ] Manual testing completed
|
||||||
|
- [ ] All existing tests pass
|
||||||
|
|
||||||
|
## Checklist
|
||||||
|
- [ ] Code follows project style guidelines
|
||||||
|
- [ ] Self-review completed
|
||||||
|
- [ ] Documentation updated (if needed)
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# AGENTS.md
|
||||||
|
|
||||||
|
This file provides high-level guidance for all Hermes agents working in this project.
|
||||||
|
|
||||||
|
## Project Mission
|
||||||
|
|
||||||
|
[Brief description of what this project is trying to achieve]
|
||||||
|
|
||||||
|
## Key Principles
|
||||||
|
|
||||||
|
- Follow the execution guidelines defined in `core-planning/docs/EXECUTION.md`
|
||||||
|
- Use the SCRUM stand-up format when reporting progress
|
||||||
|
- Coordinate primarily through the Hermes Kanban board
|
||||||
|
- Maintain clear, actionable comments on tasks
|
||||||
|
|
||||||
|
## Architecture & Constraints
|
||||||
|
|
||||||
|
[Add important technical decisions, tech stack, constraints, or gotchas here]
|
||||||
|
|
||||||
|
## Coding Standards
|
||||||
|
|
||||||
|
[Add any project-specific coding conventions, linting rules, or style guides]
|
||||||
|
|
||||||
|
## Kanban Workflow
|
||||||
|
|
||||||
|
- All work is tracked in the Hermes Kanban board
|
||||||
|
- Use clear task prefixes: `[Feature]`, `[Bug]`, `[Research]`, `[Review]`, etc.
|
||||||
|
- Follow the escalation rules defined in `core-planning/docs/KANBAN-WORKFLOW.md`
|
||||||
|
- Only escalate to the human when the defined escalation criteria are met
|
||||||
|
|
||||||
|
## Important Context
|
||||||
|
|
||||||
|
- Refer to `core-planning/docs/` for team-wide standards
|
||||||
|
- Refer to `docs/` in this repo for project-specific guidelines
|
||||||
|
- When in doubt, ask for clarification in the task comments
|
||||||
|
|
||||||
|
## Agent Roles in This Project
|
||||||
|
|
||||||
|
See the `agents/` folder for role-specific instructions.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*This file should be updated as the project evolves.*
|
||||||
@@ -1,571 +1,93 @@
|
|||||||
# AI Chess Tutor
|
# ♞ Chess Tutor
|
||||||
|
|
||||||
## The Story
|
AI-powered chess tutor with Stockfish analysis. Play against customizable AI coach personalities. Supports multiple AI models via OpenRouter.
|
||||||
I always wanted to implement an AI-based chess tutor because I like playing chess, although to be honest, I actually suck at it. I didn't find the existing tutors or big apps useful enough for my needs, so I decided to build my own approach.
|
|
||||||
|
|
||||||
This application was built using **Antigravity by Google**. I like to work with it, though sometimes it just runs away. Still, I found the end result to be quite fun to play, which is why I'm sharing it here.
|
|
||||||
|
|
||||||
## How It Works
|
|
||||||
|
|
||||||
**AI Chess Tutor** is an interactive chess learning application where you play against an AI opponent powered by Stockfish while receiving real-time coaching feedback from a Large Language Model (LLM).
|
|
||||||
|
|
||||||
### Learning Modes
|
|
||||||
|
|
||||||
**Opening Training Mode** - Learn chess openings systematically:
|
|
||||||
1. **Browse Openings**: Explore 12,379 openings organized by ECO code and family
|
|
||||||
2. **Select an Opening**: Choose from openings like French Defense, Sicilian, Ruy Lopez, etc.
|
|
||||||
3. **Read Background**: View Wikipedia context about the opening's history and strategic ideas
|
|
||||||
4. **Practice Moves**: Make moves while the AI tutor guides you through the repertoire
|
|
||||||
5. **Real-Time Feedback**: Get instant feedback on whether you're in theory or deviating
|
|
||||||
6. **Handle Deviations**: When you leave theory, choose to:
|
|
||||||
- **Undo** and return to the repertoire
|
|
||||||
- **Continue Playing** in full game mode with opening context
|
|
||||||
- **Explore** the variation further
|
|
||||||
7. **Resume Sessions**: Your progress is saved - pick up where you left off
|
|
||||||
|
|
||||||
**Tactical Practice Mode** - Master tactical patterns:
|
|
||||||
1. **Choose a Pattern**: Select from 8 tactical themes (Pin, Fork, Skewer, etc.)
|
|
||||||
2. **Solve Puzzles**: Find the winning tactical move in realistic positions
|
|
||||||
3. **Get Feedback**: Receive immediate feedback and explanations
|
|
||||||
4. **Track Progress**: Monitor your streak and success rate
|
|
||||||
5. **Learn Patterns**: Build pattern recognition through repetition
|
|
||||||
|
|
||||||
**Game Mode** - Play full games with AI coaching:
|
|
||||||
1. **Start or Resume**: From the homepage, either start a new game or continue an unfinished game
|
|
||||||
2. **Choose Your Personality**: Select from 9 unique AI coaching personalities
|
|
||||||
3. **Select Your Color**: Play as White, Black, or let the app choose randomly
|
|
||||||
4. **Play Chess**: Make your moves on the board while the Stockfish engine plays against you
|
|
||||||
5. **Get Real-Time Feedback**: After each move exchange, your AI tutor analyzes the position:
|
|
||||||
- Move quality and alternatives
|
|
||||||
- Position evaluation changes
|
|
||||||
- **Missed tactical opportunities** (pins, forks, skewers, checks, hanging pieces, material captures)
|
|
||||||
- Opening theory (when applicable)
|
|
||||||
- Strategic and positional considerations
|
|
||||||
6. **Chat with Your Tutor**: Ask questions anytime - your tutor answers in character
|
|
||||||
7. **Export Your Game**: Download your game as PGN or export the current position as FEN
|
|
||||||
8. **Resign When Needed**: End the game early with in-character tutor feedback
|
|
||||||
9. **Post-Game Analysis**: Review comprehensive analysis showing:
|
|
||||||
- All your mistakes and inaccuracies
|
|
||||||
- Missed tactical opportunities throughout the game
|
|
||||||
- Learning opportunities and improvement suggestions
|
|
||||||
|
|
||||||

|
|
||||||
*Screenshot placeholder: Main game interface with board, evaluation bar, and chat*
|
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
### Core Features
|
- **Play chess** against Stockfish with an AI coach giving you real-time feedback
|
||||||
- **Stockfish Engine**: Powerful chess engine for move analysis and opponent play
|
- **AI personalities** — choose from Hype Streamer, Professional Coach, Russian Grandmaster, and more
|
||||||
- **Opening Training Mode**: Interactive opening trainer with AI tutor guidance
|
- **Model selector** — switch between Gemini, Claude, GPT, Grok, DeepSeek, and Llama
|
||||||
- Practice 12,379 chess openings from comprehensive ECO database
|
- **Opening trainer** — practice specific openings with move validation
|
||||||
- Real-time feedback on theory adherence vs. deviations
|
- **Tactical puzzles** — test your pattern recognition
|
||||||
- Wikipedia integration for opening history and strategic context
|
- **Game analysis** — get AI feedback on your games
|
||||||
- Session persistence with resume capability
|
- **Multi-language** — English, German, French, Italian, Polish
|
||||||
- Deviation dialog with options to undo, continue, or transition to game mode
|
|
||||||
- Automatic opponent moves following repertoire lines
|
|
||||||
- **Tactical Practice Mode**: Pattern-based puzzle training with 8 tactical themes
|
|
||||||
- 20+ puzzles per pattern from Lichess database
|
|
||||||
- Patterns: Pin, Fork, Skewer, Discovered Check, Double Attack, Overloading, Back Rank Weakness, Trapped Piece
|
|
||||||
- Streak tracking and performance statistics
|
|
||||||
- **Tactical Recognition**: Automatically detects missed tactical opportunities (pins, forks, skewers, checks, hanging pieces, material captures)
|
|
||||||
- **Opening Database**: Comprehensive ECO database with 12,379 openings, metadata, and theory
|
|
||||||
- **Real-Time Evaluation**: Live position evaluation with visual evaluation bar
|
|
||||||
- **Move Analysis**: Detailed feedback on every move you make with tactical insights
|
|
||||||
- **Interactive Chat**: Ask your AI tutor questions and get personalized answers
|
|
||||||
- **Post-Game Analysis**: Review all your mistakes and missed opportunities after each game
|
|
||||||
- **Resign Option**: End games early with a resign button - your AI tutor responds in character
|
|
||||||
- **Direct Analysis Access**: Jump straight to the analysis page from the game over modal
|
|
||||||
- **Multi-Language Support**: Available in English, German, French, Italian, and Polish
|
|
||||||
- **FEN/PGN Import**: Import positions or games with automatic format detection
|
|
||||||
- **Game Export**: Download your games as PGN or export current position as FEN
|
|
||||||
- **Move History**: Visual move history table with evaluation changes and tactical annotations
|
|
||||||
- **Saved Games**: Continue unfinished games from where you left off
|
|
||||||
- **Settings Management**: Customize your experience with language preferences, API key management, and data controls
|
|
||||||
- **Mobile Apps**: iOS and Android apps with Capacitor (proprietary licensing for App Store compliance)
|
|
||||||
|
|
||||||
### AI Personalities
|
|
||||||
|
|
||||||
Choose from **9 distinct coaching personalities**, each offering a unique learning experience:
|
|
||||||
|
|
||||||
#### 📘 Opening Professor
|
|
||||||
*"This is a very instructive structure..."*
|
|
||||||
|
|
||||||
A calm, deeply knowledgeable educator who loves turning openings into understandable stories with history, plans, and model structures. Perfect for learning opening theory and understanding positional concepts.
|
|
||||||
|
|
||||||
#### 👨🏫 Professional Coach
|
|
||||||
*"Let's analyze the structure of this position"*
|
|
||||||
|
|
||||||
A strict, analytical, and straightforward chess coach focused on your improvement. Expect objective analysis, clear explanations based on chess principles, and professional teaching methods.
|
|
||||||
|
|
||||||
#### 🥃 Drunk Russian GM
|
|
||||||
*"Ach... life is pain, my boy"*
|
|
||||||
|
|
||||||
A bitter, fatalistic, but brilliant Grandmaster who has seen it all. Expect brutally honest feedback delivered with dark humor and existential commentary. This personality combines deep chess knowledge with a Dostoevsky-like atmosphere.
|
|
||||||
|
|
||||||
#### 🎧 Hype Streamer
|
|
||||||
*"BRO! THAT MOVE WAS INSANE!"*
|
|
||||||
|
|
||||||
An energetic, loud, and entertaining chess streamer who makes every game exciting. Expect dramatic reactions, Gen-Z slang, and over-the-top commentary that keeps you engaged and motivated.
|
|
||||||
|
|
||||||
#### 🧙♂️ Gandalf the Chess Wizard
|
|
||||||
*"A move is never late, nor is it early..."*
|
|
||||||
|
|
||||||
A wise and mystical chess wizard who speaks in riddles and metaphors. Combines chess wisdom with magical references and philosophical insights.
|
|
||||||
|
|
||||||
#### 🤖 Stockfish (Literal)
|
|
||||||
*"Evaluation: +0.7. Best continuation: Nf3, d5, c4..."*
|
|
||||||
|
|
||||||
The engine itself, speaking in pure chess notation and evaluations. No personality, just raw analysis and computer-like precision.
|
|
||||||
|
|
||||||
#### 😤 Toxic Gamer
|
|
||||||
*"Are you even trying? That's the worst move I've seen all day!"*
|
|
||||||
|
|
||||||
An abrasive, confrontational personality that roasts your mistakes mercilessly. Not for the faint of heart, but some players find the challenge motivating.
|
|
||||||
|
|
||||||
#### 🎭 Shakespearean Bard
|
|
||||||
*"To castle or not to castle, that is the question..."*
|
|
||||||
|
|
||||||
A theatrical personality that delivers chess analysis in Shakespearean verse and dramatic monologues. Makes every game feel like a stage performance.
|
|
||||||
|
|
||||||
#### 🧘 Zen Master
|
|
||||||
*"The board is empty, yet full of possibilities..."*
|
|
||||||
|
|
||||||
A calm, meditative personality that approaches chess as a spiritual practice. Focuses on mindfulness, patience, and finding harmony in the position.
|
|
||||||
|
|
||||||
### Tactical Recognition System
|
|
||||||
|
|
||||||
One of the standout features is the **automatic tactical pattern detection** that runs after every move:
|
|
||||||
|
|
||||||
**What it detects:**
|
|
||||||
- **Material Captures**: Safe captures of pieces and pawns (with recapture analysis)
|
|
||||||
- **Pins**: Pieces pinned to the king or more valuable pieces
|
|
||||||
- **Forks**: Pieces attacking multiple valuable targets simultaneously
|
|
||||||
- **Skewers**: Attacks forcing a valuable piece to move, exposing another
|
|
||||||
- **Checks**: Moves that give check to the opponent's king
|
|
||||||
- **Hanging Pieces**: Undefended pieces that could be captured
|
|
||||||
|
|
||||||
**How it works:**
|
|
||||||
1. After each move, the system compares your move to the engine's best move
|
|
||||||
2. If there's a significant evaluation difference, it analyzes what tactical opportunities were missed
|
|
||||||
3. The AI tutor receives this information and explains it in real-time in their characteristic style
|
|
||||||
4. All missed tactics are also shown in the post-game analysis
|
|
||||||
|
|
||||||
**Conservative approach:**
|
|
||||||
- The system uses multiple filters to avoid false positives
|
|
||||||
- Only reports tactics when there's a clear advantage
|
|
||||||
- Checks for piece safety (e.g., won't report a "capture" if the piece can be immediately recaptured)
|
|
||||||
|
|
||||||
This feature helps you learn tactical patterns naturally during gameplay, rather than just through puzzle training.
|
|
||||||
|
|
||||||
### User Experience Features
|
|
||||||
|
|
||||||
**Game Management:**
|
|
||||||
- **Save & Resume**: Your games are automatically saved to browser storage - continue playing anytime
|
|
||||||
- **Game Export**: Download your completed or in-progress games as PGN files for analysis in other tools
|
|
||||||
- **Position Export**: Export the current board position as FEN for sharing or further study
|
|
||||||
- **Game History**: Visual table showing all moves with evaluation changes and missed tactics
|
|
||||||
- **Clear All Data**: Reset your local storage from the settings page when needed
|
|
||||||
|
|
||||||
**Customization:**
|
|
||||||
- **5 Languages**: Full interface translation in English, German, French, Italian, and Polish
|
|
||||||
- **9 AI Personalities**: Choose the coaching style that motivates you best
|
|
||||||
- **Flexible Setup**: Play as White, Black, or random color selection
|
|
||||||
- **Import Games**: Start from any position using FEN or continue from a PGN game
|
|
||||||
|
|
||||||
**Analysis Tools:**
|
|
||||||
- **Real-Time Evaluation Bar**: Visual representation of position evaluation during play
|
|
||||||
- **Opening Explorer**: Automatic opening detection with theory and explanations
|
|
||||||
- **Position Analysis**: Deep-dive into any position with the analysis modal
|
|
||||||
- **Post-Game Review**: Comprehensive analysis of all mistakes and missed opportunities
|
|
||||||
|
|
||||||
**Modern Interface:**
|
|
||||||
- **Responsive Design**: Works on desktop, tablet, and mobile devices
|
|
||||||
- **Dark Mode Support**: Automatic dark/light theme based on system preferences
|
|
||||||
- **Intuitive Controls**: Drag-and-drop piece movement with visual feedback
|
|
||||||
- **Clean Layout**: Focused design that keeps the board and feedback front and center
|
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
### Option 1: Docker Compose (Recommended - Easiest!)
|
|
||||||
|
|
||||||
The fastest way to get started is using our pre-built Docker image from GitHub Container Registry:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Clone the repository
|
# Install
|
||||||
git clone https://github.com/stefan-kp/chess_tutor.git
|
|
||||||
cd chess_tutor
|
|
||||||
|
|
||||||
# (Optional) Create .env file with your API key
|
|
||||||
echo "NEXT_PUBLIC_GEMINI_API_KEY=your_api_key_here" > .env
|
|
||||||
|
|
||||||
# Start the application
|
|
||||||
docker-compose up -d
|
|
||||||
|
|
||||||
# View logs
|
|
||||||
docker-compose logs -f
|
|
||||||
```
|
|
||||||
|
|
||||||
The application will be available at `http://localhost:3050`
|
|
||||||
|
|
||||||
**What happens:**
|
|
||||||
- Docker Compose automatically pulls the latest pre-built image from `ghcr.io/stefan-kp/chess-tutor:latest`
|
|
||||||
- No build step required - the image is built automatically on every push to main via GitHub Actions
|
|
||||||
- The container starts with health checks and auto-restart enabled
|
|
||||||
|
|
||||||
### Option 2: Local Development
|
|
||||||
|
|
||||||
If you want to run the application locally for development:
|
|
||||||
|
|
||||||
#### Prerequisites
|
|
||||||
- Node.js 18+ installed
|
|
||||||
- A free Google Gemini API key
|
|
||||||
|
|
||||||
#### Steps
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Clone the repository
|
|
||||||
git clone https://github.com/stefan-kp/chess_tutor.git
|
|
||||||
cd chess_tutor
|
|
||||||
|
|
||||||
# Install dependencies
|
|
||||||
npm install
|
npm install
|
||||||
|
|
||||||
# Create .env file (optional)
|
# Run
|
||||||
echo "NEXT_PUBLIC_GEMINI_API_KEY=your_api_key_here" > .env
|
|
||||||
|
|
||||||
# Run development server
|
|
||||||
npm run dev
|
npm run dev
|
||||||
|
|
||||||
# Or build and run production
|
|
||||||
npm run build
|
|
||||||
npm start
|
|
||||||
```
|
```
|
||||||
|
|
||||||
The application will be available at `http://localhost:3050`
|
Open **http://localhost:3050** — the onboarding page will walk you through setup.
|
||||||
|
|
||||||
### Getting Your Gemini API Key
|
## Tech Stack
|
||||||
|
|
||||||
1. Visit [Google AI Studio](https://aistudio.google.com/app/apikey)
|
- **Frontend:** Next.js 16, React 19, TypeScript, Tailwind CSS 4
|
||||||
2. Sign in with your Google account
|
- **Chess:** chess.js, react-chessboard
|
||||||
3. Click "Create API Key"
|
- **Engine:** Stockfish (in-browser via stockfish.js WASM)
|
||||||
4. Copy your API key
|
- **AI:** OpenRouter API (multi-provider: Gemini, Claude, GPT, Grok, DeepSeek, Llama)
|
||||||
|
- **Mobile:** Capacitor (iOS/Android)
|
||||||
|
|
||||||
### API Key Configuration
|
## Environment
|
||||||
|
|
||||||
You have two options for providing your Gemini API key:
|
Copy `.env.example` to `.env.local`:
|
||||||
|
|
||||||
#### Option 1: Environment Variable (Recommended for Docker/Server)
|
|
||||||
1. Create a `.env` file in the project root
|
|
||||||
2. Add your API key:
|
|
||||||
```
|
```
|
||||||
NEXT_PUBLIC_GEMINI_API_KEY=your_api_key_here
|
OPENROUTER_API_KEY=sk-or-...
|
||||||
```
|
NEXT_PUBLIC_DEFAULT_MODEL=google/gemini-2.5-flash
|
||||||
3. The application will automatically use this key
|
|
||||||
|
|
||||||
#### Option 2: Browser Storage (Fallback)
|
|
||||||
1. Run the application without an API key
|
|
||||||
2. When prompted, enter your API key in the modal dialog
|
|
||||||
3. The key will be stored in your browser's localStorage
|
|
||||||
|
|
||||||
> [!NOTE]
|
|
||||||
> You can update your API key anytime by clicking the key icon in the bottom-right corner of the application.
|
|
||||||
|
|
||||||
## Advanced Deployment
|
|
||||||
|
|
||||||
### Automated Docker Builds
|
|
||||||
|
|
||||||
This project uses GitHub Actions to automatically build and publish Docker images to GitHub Container Registry (GHCR) on every push to the `main` branch.
|
|
||||||
|
|
||||||
**What this means:**
|
|
||||||
- Every commit to `main` triggers an automatic Docker build
|
|
||||||
- The latest image is always available at `ghcr.io/stefan-kp/chess-tutor:latest`
|
|
||||||
- Each build is also tagged with the commit SHA for version tracking
|
|
||||||
- No need to build locally - just pull and run!
|
|
||||||
|
|
||||||
### Using Pre-Built Images
|
|
||||||
|
|
||||||
The `docker-compose.yml` file is already configured to use the pre-built image:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
services:
|
|
||||||
chess-tutor:
|
|
||||||
image: ghcr.io/stefan-kp/chess-tutor:latest
|
|
||||||
# ... rest of configuration
|
|
||||||
```
|
```
|
||||||
|
|
||||||
This means you can deploy anywhere with just:
|
Or enter your API key through the UI on first launch.
|
||||||
```bash
|
|
||||||
docker-compose up -d
|
## Hermes Agent Integration
|
||||||
|
|
||||||
|
This repo includes Hermes agent role files for multi-agent development:
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `AGENTS.md` | High-level project guidance for Hermes agents |
|
||||||
|
| `tasks.md` | Sprint tasks with Now / Next / Backlog |
|
||||||
|
| `agents/HA_*.md` | Role-specific instructions (Architect, Coder, DevOps, etc.) |
|
||||||
|
| `.gitea/` | Issue and PR templates |
|
||||||
|
| `docs/WORKFLOW.md` | Collaboration workflow |
|
||||||
|
|
||||||
|
## Available AI Models
|
||||||
|
|
||||||
|
| Model | Provider |
|
||||||
|
|-------|----------|
|
||||||
|
| Gemini 2.5 Flash | Google |
|
||||||
|
| Gemini 2.5 Pro | Google |
|
||||||
|
| Claude Sonnet 4 | Anthropic |
|
||||||
|
| Claude 3.5 Haiku | Anthropic |
|
||||||
|
| GPT-4o | OpenAI |
|
||||||
|
| GPT-4o Mini | OpenAI |
|
||||||
|
| DeepSeek Chat | DeepSeek |
|
||||||
|
| Grok 3 | xAI |
|
||||||
|
| Llama 3.1 70B | Meta |
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
|
src/
|
||||||
### Manual Docker Build (Optional)
|
├── app/ # Next.js pages and API routes
|
||||||
|
│ ├── api/v1/llm/ # OpenRouter-backed AI endpoints
|
||||||
If you prefer to build the image yourself:
|
│ ├── learning/ # Opening trainer and tactics
|
||||||
|
│ ├── onboarding/ # First-run setup flow
|
||||||
```bash
|
│ └── settings/ # Game settings
|
||||||
# Build the image
|
├── components/ # React components
|
||||||
docker build -t chess-tutor:latest .
|
│ ├── ChessGame.tsx # Main game component
|
||||||
|
│ ├── Tutor.tsx # AI tutor chat panel
|
||||||
# Run the container
|
│ ├── ModelSelector.tsx # Model dropdown (OpenRouter)
|
||||||
docker run -d \
|
│ └── ...
|
||||||
--name chess-tutor \
|
└── lib/
|
||||||
-p 3050:3050 \
|
├── openrouter.ts # OpenRouter abstraction layer
|
||||||
-e NEXT_PUBLIC_GEMINI_API_KEY=your_api_key_here \
|
├── server/tutorPrompt.ts # Prompt templates
|
||||||
--restart unless-stopped \
|
└── engine/ # Stockfish integration
|
||||||
chess-tutor:latest
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Nginx Reverse Proxy
|
|
||||||
|
|
||||||
For production deployment behind nginx, use the provided `nginx.conf.example`:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Copy the example configuration
|
|
||||||
sudo cp nginx.conf.example /etc/nginx/sites-available/chess-tutor
|
|
||||||
|
|
||||||
# Edit the configuration
|
|
||||||
sudo nano /etc/nginx/sites-available/chess-tutor
|
|
||||||
# Update: server_name, SSL certificates (if using HTTPS)
|
|
||||||
|
|
||||||
# Enable the site
|
|
||||||
sudo ln -s /etc/nginx/sites-available/chess-tutor /etc/nginx/sites-enabled/
|
|
||||||
|
|
||||||
# Test nginx configuration
|
|
||||||
sudo nginx -t
|
|
||||||
|
|
||||||
# Reload nginx
|
|
||||||
sudo systemctl reload nginx
|
|
||||||
```
|
|
||||||
|
|
||||||
## API Endpoints
|
|
||||||
|
|
||||||
The application provides HTTP API endpoints that allow external applications to access chess engine evaluation and AI tutor functionality. This enables integration with other tools, mobile apps, or custom interfaces.
|
|
||||||
|
|
||||||
### Available Endpoints
|
|
||||||
|
|
||||||
#### 1. Stockfish Evaluation API
|
|
||||||
|
|
||||||
**Endpoint:** `POST /api/v1/stockfish`
|
|
||||||
|
|
||||||
Evaluates a chess position using the Stockfish engine running server-side.
|
|
||||||
|
|
||||||
**Request Body:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"fen": "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1",
|
|
||||||
"depth": 15,
|
|
||||||
"multiPV": 1
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Response:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"evaluation": {
|
|
||||||
"bestMove": "e2e4",
|
|
||||||
"ponder": "e7e5",
|
|
||||||
"score": 20,
|
|
||||||
"mate": null,
|
|
||||||
"depth": 15
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Parameters:**
|
|
||||||
- `fen` (required): FEN string of the position to evaluate
|
|
||||||
- `depth` (optional): Search depth (default: 15)
|
|
||||||
- `multiPV` (optional): Number of principal variations (default: 1)
|
|
||||||
|
|
||||||
**Notes:**
|
|
||||||
- Scores are normalized from White's perspective (positive = White advantage)
|
|
||||||
- Mate values indicate moves to mate (positive = White mates, negative = Black mates)
|
|
||||||
|
|
||||||
#### 2. AI Tutor Chat API
|
|
||||||
|
|
||||||
**Endpoint:** `POST /api/v1/llm/chat`
|
|
||||||
|
|
||||||
Generates AI tutor responses with enforced personality and chess context.
|
|
||||||
|
|
||||||
**Request Body:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"apiKey": "your-gemini-api-key",
|
|
||||||
"personalityId": "bobby_fischer",
|
|
||||||
"language": "en",
|
|
||||||
"playerColor": "white",
|
|
||||||
"message": "How should I continue?",
|
|
||||||
"context": {
|
|
||||||
"currentFen": "rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1",
|
|
||||||
"evaluation": {
|
|
||||||
"score": 20,
|
|
||||||
"mate": null,
|
|
||||||
"bestMove": "e7e5"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"history": [
|
|
||||||
{ "role": "user", "text": "Previous question" },
|
|
||||||
{ "role": "model", "text": "Previous answer" }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Response:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"reply": "Ah, the classic e4 opening! You're following in the footsteps of champions..."
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Parameters:**
|
|
||||||
- `apiKey` (required): Your Google Gemini API key
|
|
||||||
- `personalityId` (required): ID of the tutor personality (see `/api/v1/personalities`)
|
|
||||||
- `language` (required): Language code (`en`, `de`, `fr`, `it`, `pl`)
|
|
||||||
- `playerColor` (required): Player's color (`white` or `black`)
|
|
||||||
- `message` (required): User's message/question
|
|
||||||
- `context` (optional): Chess position context (FEN, evaluation, openings, tactics)
|
|
||||||
- `history` (optional): Previous conversation history
|
|
||||||
- `modelName` (optional): Gemini model name (default: `gemini-2.5-flash`)
|
|
||||||
|
|
||||||
**Notes:**
|
|
||||||
- The API key is provided by the client (bring your own key)
|
|
||||||
- Server-side prompt generation ensures consistent tutor behavior
|
|
||||||
- System prompts enforce the dual role (opponent + tutor)
|
|
||||||
|
|
||||||
#### 3. Personalities API
|
|
||||||
|
|
||||||
**Endpoint:** `GET /api/v1/personalities`
|
|
||||||
|
|
||||||
Returns the list of available AI tutor personalities.
|
|
||||||
|
|
||||||
**Response:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"personalities": [
|
|
||||||
{
|
|
||||||
"id": "bobby_fischer",
|
|
||||||
"name": "Bobby Fischer",
|
|
||||||
"description": "Aggressive, confident, and brutally honest...",
|
|
||||||
"image": "/personalities/bobby_fischer.png"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Notes:**
|
|
||||||
- System prompts are excluded from the response for security
|
|
||||||
- Use the `id` field when calling the chat API
|
|
||||||
|
|
||||||
### API Usage Example
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Evaluate a position
|
|
||||||
curl -X POST http://localhost:3050/api/v1/stockfish \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{
|
|
||||||
"fen": "rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1",
|
|
||||||
"depth": 15
|
|
||||||
}'
|
|
||||||
|
|
||||||
# Get available personalities
|
|
||||||
curl http://localhost:3050/api/v1/personalities
|
|
||||||
|
|
||||||
# Chat with AI tutor
|
|
||||||
curl -X POST http://localhost:3050/api/v1/llm/chat \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{
|
|
||||||
"apiKey": "your-gemini-api-key",
|
|
||||||
"personalityId": "bobby_fischer",
|
|
||||||
"language": "en",
|
|
||||||
"playerColor": "white",
|
|
||||||
"message": "What should I play here?",
|
|
||||||
"context": {
|
|
||||||
"currentFen": "rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1"
|
|
||||||
}
|
|
||||||
}'
|
|
||||||
```
|
|
||||||
|
|
||||||
For detailed API documentation, see:
|
|
||||||
- [Stockfish API Documentation](docs/stockfish_api.md)
|
|
||||||
- [LLM API Documentation](docs/llm_api.md)
|
|
||||||
|
|
||||||
## Configuration
|
|
||||||
|
|
||||||
You can configure the application using environment variables in your `.env` file or Docker Compose configuration:
|
|
||||||
|
|
||||||
| Variable | Description | Default |
|
|
||||||
|----------|-------------|---------|
|
|
||||||
| `NEXT_PUBLIC_GEMINI_API_KEY` | Google Gemini API Key for AI features | (Required for AI) |
|
|
||||||
| `NEXT_PUBLIC_DEBUG` | Enable debug mode to see all LLM prompts and responses | `false` |
|
|
||||||
| `IMPRINT_URL` | External URL for the Imprint link in the footer. If not set, an internal page is used. | Internal Page |
|
|
||||||
| `DATA_PRIVACY_RESPONSIBLE_PERSON` | Name of the person responsible for data privacy (shown on /privacy page). | Placeholder |
|
|
||||||
|
|
||||||
### Debug Mode
|
|
||||||
|
|
||||||
Debug mode is a powerful feature that helps you understand and troubleshoot how the AI tutor works by showing you all the prompts sent to the LLM and the responses received.
|
|
||||||
|
|
||||||
**To enable debug mode:**
|
|
||||||
|
|
||||||
1. Add to your `.env` file:
|
|
||||||
```
|
|
||||||
NEXT_PUBLIC_DEBUG=true
|
|
||||||
```
|
|
||||||
|
|
||||||
2. Restart the application (or rebuild if using Docker)
|
|
||||||
|
|
||||||
**What debug mode shows:**
|
|
||||||
|
|
||||||
- **All LLM Prompts**: See exactly what context, instructions, and data are sent to the AI
|
|
||||||
- **All LLM Responses**: View the raw responses before they're displayed in the chat
|
|
||||||
- **Timestamps**: Track when each interaction occurred
|
|
||||||
- **Interaction Types**: Distinguish between move analysis, user questions, and other triggers
|
|
||||||
|
|
||||||
**How to use it:**
|
|
||||||
|
|
||||||
- **Floating Panel**: A debug panel appears in the bottom-right corner showing all interactions
|
|
||||||
- **Copy to Clipboard**: Click the copy button to save prompts/responses for analysis
|
|
||||||
- **Clear History**: Clear the debug log when needed
|
|
||||||
- **Expandable Details**: Click on any entry to see the full prompt and response
|
|
||||||
|
|
||||||
**Why it's useful:**
|
|
||||||
|
|
||||||
- **Troubleshooting**: Identify issues with AI responses or unexpected behavior
|
|
||||||
- **Learning**: Understand how the system constructs prompts and provides context
|
|
||||||
- **Bug Reports**: Include debug output when reporting issues
|
|
||||||
- **Customization**: See what data is available if you want to modify the prompts
|
|
||||||
|
|
||||||
**Example use cases:**
|
|
||||||
|
|
||||||
1. **Verify Position Context**: Check that the correct FEN positions are being sent
|
|
||||||
2. **Check Evaluation Data**: Ensure mate scores and centipawn values are correct
|
|
||||||
3. **Opening Detection**: See which openings are being identified and sent to the AI
|
|
||||||
4. **Tactical Analysis**: View the tactical opportunities detected by the system
|
|
||||||
|
|
||||||
> [!WARNING]
|
|
||||||
> Debug mode is intended for development and troubleshooting. It may impact performance and should not be used in production environments.
|
|
||||||
|
|
||||||
## License
|
|
||||||
|
|
||||||
Copyright (c) 2024 KaProblem (https://www.kaproblem.com). All rights reserved.
|
|
||||||
|
|
||||||
This project uses **dual licensing** (required for App Store compliance):
|
|
||||||
|
|
||||||
- **Source Code**: GPL-3.0 (see [LICENSE](LICENSE))
|
|
||||||
- **Mobile Apps**: Proprietary (copyright holder only)
|
|
||||||
|
|
||||||
**Why Dual Licensing?**
|
|
||||||
App Store and Google Play terms are incompatible with GPL-3.0. Mobile builds exclude GPL code (Stockfish.js) and use API calls instead, allowing proprietary licensing for app store distribution.
|
|
||||||
|
|
||||||
**Important for Third Parties:**
|
|
||||||
- You may use this code under GPL-3.0 terms
|
|
||||||
- You CANNOT distribute GPL apps on App Store/Google Play (platform restrictions)
|
|
||||||
- You CANNOT create proprietary mobile apps from this code
|
|
||||||
- Only the copyright holder can distribute proprietary versions
|
|
||||||
- See [COPYRIGHT](COPYRIGHT) and [LICENSING.md](LICENSING.md) for details
|
|
||||||
|
|
||||||
This application uses [Stockfish](https://stockfishchess.org/), which is licensed under GPL-3.0 (web builds only; mobile builds use API calls).
|
|
||||||
|
|
||||||
## Credits
|
## Credits
|
||||||
- Opening collection originally by [ragizaki/ChessOpeningsRecommender](https://github.com/ragizaki/ChessOpeningsRecommender)
|
|
||||||
- Chess engine: [Stockfish](https://stockfishchess.org/) (GPLv3)
|
|
||||||
- LLM: [Google Gemini](https://ai.google.dev/)
|
|
||||||
|
|
||||||
|
Based on [stefan-kp/chess_tutor](https://github.com/stefan-kp/chess_tutor) — licensed under GPL-3.0.
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# HA_Architect — Role Instructions
|
||||||
|
|
||||||
|
## Primary Responsibilities
|
||||||
|
|
||||||
|
- Define and evolve the system architecture
|
||||||
|
- Make key technical decisions
|
||||||
|
- Ensure consistency across the codebase
|
||||||
|
- Support `HA_Planner` with technical direction
|
||||||
|
|
||||||
|
## Kanban Responsibilities
|
||||||
|
|
||||||
|
- Review major changes that have significant architectural impact
|
||||||
|
- Document architectural decisions that affect task structure or workflow
|
||||||
|
- Use the SCRUM stand-up format for updates
|
||||||
|
- Only escalate to the human when escalation rules are met
|
||||||
|
|
||||||
|
## Interaction with Other Agents
|
||||||
|
|
||||||
|
- Work closely with `HA_Planner` on technical planning
|
||||||
|
- Guide `HA_Coder` and `HA_Reviewer` on architectural concerns
|
||||||
|
|
||||||
|
## Project-Specific Notes
|
||||||
|
|
||||||
|
[Add any project-specific architectural principles or constraints here]
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# HA_Coder — Role Instructions
|
||||||
|
|
||||||
|
## Primary Responsibilities
|
||||||
|
|
||||||
|
- Implement features and fixes based on task requirements
|
||||||
|
- Write clean, maintainable, and well-tested code
|
||||||
|
- Move tasks through the Kanban board as work progresses
|
||||||
|
- Follow the SCRUM stand-up format for all updates
|
||||||
|
|
||||||
|
## Kanban Responsibilities
|
||||||
|
|
||||||
|
- Only move a task to `running` when ready to work on it
|
||||||
|
- Use clear, meaningful comments when changing status
|
||||||
|
- Follow the SCRUM stand-up format (`What I did / What I'm doing next / Blockers`)
|
||||||
|
- Ask for clarification in comments when requirements are unclear
|
||||||
|
- Only escalate to the human when the defined escalation rules are met
|
||||||
|
|
||||||
|
## Interaction with Other Agents
|
||||||
|
|
||||||
|
- Collaborate with `HA_Reviewer` during code reviews
|
||||||
|
- Work with `HA_Tester` to ensure adequate test coverage
|
||||||
|
- Coordinate with `HA_Orchestrator` on workspace and handoff needs
|
||||||
|
|
||||||
|
## Project-Specific Notes
|
||||||
|
|
||||||
|
[Add any project-specific coding guidelines or constraints here]
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# HA_DevOps — Role Instructions
|
||||||
|
|
||||||
|
## Primary Responsibilities
|
||||||
|
|
||||||
|
- Manage CI/CD pipelines and deployment processes
|
||||||
|
- Handle infrastructure and environment concerns
|
||||||
|
- Support reliable and repeatable releases
|
||||||
|
- Follow the SCRUM stand-up format for updates
|
||||||
|
|
||||||
|
## Kanban Responsibilities
|
||||||
|
|
||||||
|
- Track infrastructure and pipeline-related tasks on the Kanban board
|
||||||
|
- Use clear comments when deployment or environment issues block progress
|
||||||
|
- Coordinate with other agents on deployment-related tasks
|
||||||
|
- Only escalate to the human when escalation rules are met
|
||||||
|
|
||||||
|
## Interaction with Other Agents
|
||||||
|
|
||||||
|
- Support `HA_Coder` and `HA_Tester` with pipeline and environment needs
|
||||||
|
- Work with `HA_Orchestrator` on workspace and deployment coordination
|
||||||
|
|
||||||
|
## Project-Specific Notes
|
||||||
|
|
||||||
|
[Add any project-specific infrastructure, CI/CD, or deployment guidelines here]
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
# HA_Orchestrator — Role Instructions
|
||||||
|
|
||||||
|
## Primary Responsibilities
|
||||||
|
|
||||||
|
- Manage overall task flow on the Kanban board
|
||||||
|
- Handle handoffs between agents
|
||||||
|
- Unblock tasks where possible
|
||||||
|
- Coordinate workspace setup for new tasks
|
||||||
|
|
||||||
|
## Kanban Responsibilities
|
||||||
|
|
||||||
|
- Assign appropriate workspaces (`worktree`, `dir:`, or `scratch`)
|
||||||
|
- Monitor the board for stalled or blocked items
|
||||||
|
- Enforce the use of the SCRUM stand-up format in comments
|
||||||
|
- Facilitate smooth handoffs between roles
|
||||||
|
- Only escalate to the human when escalation rules are met
|
||||||
|
|
||||||
|
## Interaction with Other Agents
|
||||||
|
|
||||||
|
- Work closely with `HA_Planner` on prioritization
|
||||||
|
- Support `HA_Coder`, `HA_Reviewer`, and `HA_Tester` with context and handoffs
|
||||||
|
|
||||||
|
## Project-Specific Notes
|
||||||
|
|
||||||
|
[Add any project-specific orchestration considerations here]
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# HA_Planner — Role Instructions
|
||||||
|
|
||||||
|
## Primary Responsibilities
|
||||||
|
|
||||||
|
- Break down high-level goals into actionable tasks
|
||||||
|
- Maintain priority and sequencing on the Kanban board
|
||||||
|
- Aggregate agent status updates and deliver regular reports to the human
|
||||||
|
- Identify when escalation to the human is required
|
||||||
|
|
||||||
|
## Kanban Responsibilities
|
||||||
|
|
||||||
|
- Create well-structured tasks with clear acceptance criteria
|
||||||
|
- Ensure agents follow the SCRUM stand-up format in comments
|
||||||
|
- Monitor for systemic blockers and coordinate resolution
|
||||||
|
- Only escalate to the human when the defined escalation rules are met
|
||||||
|
- Maintain overall task hygiene (clear titles, proper status movement, good comments)
|
||||||
|
|
||||||
|
## Interaction with Other Agents
|
||||||
|
|
||||||
|
- Work closely with `HA_Architect` on technical direction
|
||||||
|
- Coordinate with `HA_Orchestrator` on task flow and handoffs
|
||||||
|
- Review progress across all roles regularly
|
||||||
|
|
||||||
|
## Project-Specific Notes
|
||||||
|
|
||||||
|
[Add any project-specific planning considerations here]
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# HA_Reviewer — Role Instructions
|
||||||
|
|
||||||
|
## Primary Responsibilities
|
||||||
|
|
||||||
|
- Review code, documentation, and deliverables for quality and correctness
|
||||||
|
- Provide constructive feedback
|
||||||
|
- Move tasks from review to done or request changes
|
||||||
|
|
||||||
|
## Kanban Responsibilities
|
||||||
|
|
||||||
|
- Use the SCRUM stand-up format when updating tasks
|
||||||
|
- Be specific and actionable in feedback
|
||||||
|
- Clearly distinguish between "request changes" and "block"
|
||||||
|
- Only escalate to the human when escalation rules are met
|
||||||
|
|
||||||
|
## Interaction with Other Agents
|
||||||
|
|
||||||
|
- Work closely with `HA_Coder` during reviews
|
||||||
|
- Coordinate with `HA_Tester` on validation coverage
|
||||||
|
|
||||||
|
## Project-Specific Notes
|
||||||
|
|
||||||
|
[Add any project-specific review guidelines here]
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# HA_Tester — Role Instructions
|
||||||
|
|
||||||
|
## Primary Responsibilities
|
||||||
|
|
||||||
|
- Design and implement tests for new features and fixes
|
||||||
|
- Validate that deliverables meet acceptance criteria
|
||||||
|
- Report test results clearly in task comments
|
||||||
|
- Follow the SCRUM stand-up format for updates
|
||||||
|
|
||||||
|
## Kanban Responsibilities
|
||||||
|
|
||||||
|
- Use the SCRUM stand-up format when reporting test results
|
||||||
|
- Clearly document test coverage and any gaps
|
||||||
|
- Coordinate with `HA_Coder` on failing tests or coverage issues
|
||||||
|
- Only escalate to the human when escalation rules are met
|
||||||
|
|
||||||
|
## Interaction with Other Agents
|
||||||
|
|
||||||
|
- Work closely with `HA_Coder` during implementation
|
||||||
|
- Support `HA_Reviewer` with validation evidence
|
||||||
|
|
||||||
|
## Project-Specific Notes
|
||||||
|
|
||||||
|
[Add any project-specific testing guidelines or frameworks here]
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
# Project Workflow
|
||||||
|
|
||||||
|
This document describes how agents and the human should work together in this specific project.
|
||||||
|
|
||||||
|
## Coordination
|
||||||
|
|
||||||
|
- All work is tracked and coordinated through the Hermes Kanban board.
|
||||||
|
- Agents must use the SCRUM stand-up format when posting updates.
|
||||||
|
- See `core-planning/docs/KANBAN-WORKFLOW.md` for team-wide Kanban rules.
|
||||||
|
|
||||||
|
## Escalation
|
||||||
|
|
||||||
|
Escalation to the human only occurs when the rules defined in `core-planning/docs/KANBAN-WORKFLOW.md` are met. Agents and `HA_Planner` should resolve issues first.
|
||||||
|
|
||||||
|
## Status Reporting
|
||||||
|
|
||||||
|
`HA_Planner` will deliver regular aggregated status reports to the human using input from all agents.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
- Refer to the root `AGENTS.md` for high-level project guidance.
|
||||||
|
- Refer to `core-planning/docs/` for team standards and execution guidelines.
|
||||||
|
- Role-specific instructions are located in the `agents/` folder.
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
When a new agent begins work on this project, they should:
|
||||||
|
|
||||||
|
1. Read `AGENTS.md`
|
||||||
|
2. Review the current state of the Kanban board
|
||||||
|
3. Read relevant role instructions in `agents/`
|
||||||
|
4. Follow the SCRUM format for all updates
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Update this file with any project-specific workflow nuances.*
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { getGenAIModel } from "@/lib/gemini";
|
|
||||||
import { PERSONALITIES } from "@/lib/personalities";
|
import { PERSONALITIES } from "@/lib/personalities";
|
||||||
import { SupportedLanguage } from "@/lib/i18n/translations";
|
import { SupportedLanguage } from "@/lib/i18n/translations";
|
||||||
import {
|
import {
|
||||||
@@ -9,6 +8,8 @@ import {
|
|||||||
TutorContext,
|
TutorContext,
|
||||||
TutorPlayerColor,
|
TutorPlayerColor,
|
||||||
} from "@/lib/server/tutorPrompt";
|
} from "@/lib/server/tutorPrompt";
|
||||||
|
import { tutorChat } from "@/lib/openrouter";
|
||||||
|
import type { ModelId } from "@/lib/openrouter";
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
@@ -60,19 +61,27 @@ export async function POST(request: NextRequest) {
|
|||||||
return NextResponse.json({ error: "Unknown personality" }, { status: 400 });
|
return NextResponse.json({ error: "Unknown personality" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const model = getGenAIModel(apiKey, modelName ?? "gemini-2.5-flash");
|
const systemPrompt = buildTutorSystemHistory(personality, language, playerColor)
|
||||||
const systemHistory = buildTutorSystemHistory(personality, language, playerColor);
|
.map((m) => m.text)
|
||||||
const chat = model.startChat({
|
.join("\n");
|
||||||
history: [...systemHistory, ...normalizeHistory(history)],
|
|
||||||
});
|
// Use provided model or a sensible default via OpenRouter
|
||||||
|
const model: ModelId = (modelName as ModelId) || "google/gemini-2.5-flash";
|
||||||
|
|
||||||
|
const normalizedHistory = normalizeHistory(history).map((m) => ({
|
||||||
|
role: m.role as "user" | "assistant",
|
||||||
|
content: m.text,
|
||||||
|
}));
|
||||||
|
|
||||||
const prompt = buildTutorPrompt(message, context, language);
|
const prompt = buildTutorPrompt(message, context, language);
|
||||||
const response = await chat.sendMessage(prompt);
|
const result = await tutorChat(apiKey, model, systemPrompt, normalizedHistory, prompt);
|
||||||
const text = response.response.text();
|
|
||||||
|
|
||||||
return NextResponse.json({ reply: text });
|
return NextResponse.json({ reply: result.text });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("LLM chat error", error);
|
console.error("LLM chat error", error);
|
||||||
return NextResponse.json({ error: "Failed to generate tutor response" }, { status: 500 });
|
return NextResponse.json(
|
||||||
|
{ error: `Failed to generate tutor response: ${(error as Error).message}` },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,19 +1,16 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { GoogleGenerativeAI } from '@google/generative-ai';
|
|
||||||
import {
|
import {
|
||||||
OPENING_TUTOR_SYSTEM_PROMPT,
|
OPENING_TUTOR_SYSTEM_PROMPT,
|
||||||
OPENING_TUTOR_TEMPERATURE,
|
OPENING_TUTOR_TEMPERATURE,
|
||||||
OPENING_TUTOR_MAX_TOKENS,
|
OPENING_TUTOR_MAX_TOKENS,
|
||||||
generateFallbackExplanation,
|
generateFallbackExplanation,
|
||||||
} from '@/lib/server/openingTutorPrompt';
|
} from '@/lib/server/openingTutorPrompt';
|
||||||
|
import { simpleCompletion } from '@/lib/openrouter';
|
||||||
|
import type { ModelId } from '@/lib/openrouter';
|
||||||
|
|
||||||
export const runtime = 'nodejs';
|
export const runtime = 'nodejs';
|
||||||
export const maxDuration = 10; // 10 second timeout
|
export const maxDuration = 10;
|
||||||
|
|
||||||
/**
|
|
||||||
* Opening Explanation API Endpoint
|
|
||||||
* Generates educational explanations for chess moves using LLM
|
|
||||||
*/
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
@@ -24,6 +21,7 @@ export async function POST(request: NextRequest) {
|
|||||||
theoreticalMoves,
|
theoreticalMoves,
|
||||||
evalChange,
|
evalChange,
|
||||||
bestMove,
|
bestMove,
|
||||||
|
modelName,
|
||||||
} = body;
|
} = body;
|
||||||
|
|
||||||
if (!prompt) {
|
if (!prompt) {
|
||||||
@@ -33,10 +31,10 @@ export async function POST(request: NextRequest) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for API key
|
// Check for OpenRouter API key
|
||||||
const apiKey = process.env.GEMINI_API_KEY;
|
const apiKey = process.env.OPENROUTER_API_KEY;
|
||||||
if (!apiKey) {
|
if (!apiKey) {
|
||||||
console.warn('GEMINI_API_KEY not configured, using fallback explanation');
|
console.warn('OPENROUTER_API_KEY not configured, using fallback explanation');
|
||||||
const fallback = generateFallbackExplanation(
|
const fallback = generateFallbackExplanation(
|
||||||
category,
|
category,
|
||||||
moveSan,
|
moveSan,
|
||||||
@@ -50,24 +48,16 @@ export async function POST(request: NextRequest) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize Gemini API
|
const model: ModelId = (modelName as ModelId) || 'google/gemini-2.5-flash';
|
||||||
const genAI = new GoogleGenerativeAI(apiKey);
|
|
||||||
const model = genAI.getGenerativeModel({
|
const result = await simpleCompletion(apiKey, model, OPENING_TUTOR_SYSTEM_PROMPT, prompt, {
|
||||||
model: 'gemini-1.5-flash',
|
|
||||||
generationConfig: {
|
|
||||||
temperature: OPENING_TUTOR_TEMPERATURE,
|
temperature: OPENING_TUTOR_TEMPERATURE,
|
||||||
maxOutputTokens: OPENING_TUTOR_MAX_TOKENS,
|
maxTokens: OPENING_TUTOR_MAX_TOKENS,
|
||||||
},
|
|
||||||
systemInstruction: OPENING_TUTOR_SYSTEM_PROMPT,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Generate explanation
|
const explanation = result.text;
|
||||||
const result = await model.generateContent(prompt);
|
|
||||||
const response = result.response;
|
|
||||||
const explanation = response.text();
|
|
||||||
|
|
||||||
if (!explanation || explanation.trim().length === 0) {
|
if (!explanation || explanation.trim().length === 0) {
|
||||||
// Empty response - use fallback
|
|
||||||
const fallback = generateFallbackExplanation(
|
const fallback = generateFallbackExplanation(
|
||||||
category,
|
category,
|
||||||
moveSan,
|
moveSan,
|
||||||
@@ -88,7 +78,6 @@ export async function POST(request: NextRequest) {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('LLM explanation error:', error);
|
console.error('LLM explanation error:', error);
|
||||||
|
|
||||||
// Try to extract fallback params from request
|
|
||||||
let fallbackExplanation = 'Unable to generate explanation at this time.';
|
let fallbackExplanation = 'Unable to generate explanation at this time.';
|
||||||
try {
|
try {
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
@@ -102,7 +91,7 @@ export async function POST(request: NextRequest) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Ignore fallback generation errors
|
// Ignore
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ export default function OnboardingPage() {
|
|||||||
const [consentGiven, setConsentGiven] = useState(false);
|
const [consentGiven, setConsentGiven] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const storedKey = localStorage.getItem("gemini_api_key");
|
const storedKey = localStorage.getItem("openrouter_api_key");
|
||||||
const storedLang = localStorage.getItem("chess_tutor_language");
|
const storedLang = localStorage.getItem("chess_tutor_language");
|
||||||
|
|
||||||
if (storedLang) {
|
if (storedLang) {
|
||||||
@@ -69,7 +69,7 @@ export default function OnboardingPage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
localStorage.setItem("gemini_api_key", trimmed);
|
localStorage.setItem("openrouter_api_key", trimmed);
|
||||||
localStorage.setItem("chess_tutor_language", language);
|
localStorage.setItem("chess_tutor_language", language);
|
||||||
router.push("/");
|
router.push("/");
|
||||||
};
|
};
|
||||||
@@ -192,7 +192,7 @@ export default function OnboardingPage() {
|
|||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
<a
|
<a
|
||||||
href="https://aistudio.google.com/app/apikey"
|
href="https://openrouter.ai/keys"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
className="inline-flex items-center gap-2 text-blue-600 hover:underline font-semibold"
|
className="inline-flex items-center gap-2 text-blue-600 hover:underline font-semibold"
|
||||||
|
|||||||
+1
-1
@@ -43,7 +43,7 @@ export default function Home() {
|
|||||||
hasInitializedRef.current = true;
|
hasInitializedRef.current = true;
|
||||||
|
|
||||||
// Check for API Key
|
// Check for API Key
|
||||||
const apiKey = localStorage.getItem("gemini_api_key");
|
const apiKey = localStorage.getItem("openrouter_api_key");
|
||||||
if (!apiKey) {
|
if (!apiKey) {
|
||||||
router.push("/onboarding");
|
router.push("/onboarding");
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -2,19 +2,26 @@
|
|||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Key } from "lucide-react";
|
import { Key } from "lucide-react";
|
||||||
|
import { ModelSelector } from "./ModelSelector";
|
||||||
|
import type { ModelId } from "@/lib/openrouter";
|
||||||
|
import { DEFAULT_MODEL } from "@/lib/openrouter";
|
||||||
|
|
||||||
interface APIKeyInputProps {
|
interface APIKeyInputProps {
|
||||||
onKeySubmit: (key: string) => void;
|
onKeySubmit: (key: string) => void;
|
||||||
|
onModelChange?: (model: ModelId) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function APIKeyInput({ onKeySubmit }: APIKeyInputProps) {
|
export function APIKeyInput({ onKeySubmit, onModelChange }: APIKeyInputProps) {
|
||||||
const [key, setKey] = useState("");
|
const [key, setKey] = useState("");
|
||||||
const envKey = process.env.NEXT_PUBLIC_GEMINI_API_KEY;
|
const envKey = process.env.NEXT_PUBLIC_OPENROUTER_API_KEY;
|
||||||
const storedKey = typeof window !== "undefined" ? localStorage.getItem("gemini_api_key") : null;
|
const storedKey = typeof window !== "undefined" ? localStorage.getItem("openrouter_api_key") : null;
|
||||||
const resolvedKey = envKey || storedKey;
|
const resolvedKey = envKey || storedKey;
|
||||||
const [isOpen, setIsOpen] = useState(() => !resolvedKey);
|
const [isOpen, setIsOpen] = useState(() => !resolvedKey);
|
||||||
const [consentGiven, setConsentGiven] = useState(false);
|
const [consentGiven, setConsentGiven] = useState(false);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
|
const [selectedModel, setSelectedModel] = useState<ModelId>(
|
||||||
|
() => (typeof window !== "undefined" ? (localStorage.getItem("openrouter_model") as ModelId) || DEFAULT_MODEL : DEFAULT_MODEL)
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (resolvedKey) {
|
if (resolvedKey) {
|
||||||
@@ -23,6 +30,12 @@ export function APIKeyInput({ onKeySubmit }: APIKeyInputProps) {
|
|||||||
}
|
}
|
||||||
}, [onKeySubmit, resolvedKey]);
|
}, [onKeySubmit, resolvedKey]);
|
||||||
|
|
||||||
|
const handleModelChange = (model: ModelId) => {
|
||||||
|
setSelectedModel(model);
|
||||||
|
localStorage.setItem("openrouter_model", model);
|
||||||
|
onModelChange?.(model);
|
||||||
|
};
|
||||||
|
|
||||||
const handleSubmit = (e: React.FormEvent) => {
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
@@ -36,38 +49,59 @@ export function APIKeyInput({ onKeySubmit }: APIKeyInputProps) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
localStorage.setItem("gemini_api_key", key.trim());
|
localStorage.setItem("openrouter_api_key", key.trim());
|
||||||
|
localStorage.setItem("openrouter_model", selectedModel);
|
||||||
onKeySubmit(key.trim());
|
onKeySubmit(key.trim());
|
||||||
|
onModelChange?.(selectedModel);
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
setError("");
|
setError("");
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!isOpen) {
|
if (!isOpen) {
|
||||||
return (
|
return (
|
||||||
|
<div className="fixed bottom-4 right-4 flex items-center gap-2">
|
||||||
|
<ModelSelector
|
||||||
|
selectedModel={selectedModel}
|
||||||
|
onModelChange={handleModelChange}
|
||||||
|
apiKeyConfigured={!!resolvedKey}
|
||||||
|
/>
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsOpen(true)}
|
onClick={() => setIsOpen(true)}
|
||||||
className="fixed bottom-4 right-4 p-2 bg-gray-200 dark:bg-gray-800 rounded-full hover:bg-gray-300 dark:hover:bg-gray-700 transition-colors"
|
className="p-2 bg-gray-200 dark:bg-gray-800 rounded-full hover:bg-gray-300 dark:hover:bg-gray-700 transition-colors"
|
||||||
title="Update API Key"
|
title="Update API Key"
|
||||||
>
|
>
|
||||||
<Key size={20} />
|
<Key size={20} />
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||||
<div className="bg-white dark:bg-gray-800 p-6 rounded-lg shadow-xl max-w-md w-full mx-4">
|
<div className="bg-white dark:bg-gray-800 p-6 rounded-lg shadow-xl max-w-md w-full mx-4">
|
||||||
<h2 className="text-xl font-bold mb-4">Enter Gemini API Key</h2>
|
<h2 className="text-xl font-bold mb-4">Enter OpenRouter API Key</h2>
|
||||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
|
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
|
||||||
To receive AI feedback, please enter your Google Gemini API key.
|
To receive AI coaching, enter your <a href="https://openrouter.ai/keys" target="_blank" rel="noreferrer" className="text-blue-500 hover:underline">OpenRouter API key</a>.
|
||||||
It will be stored locally in your browser.
|
It will be stored locally in your browser.
|
||||||
|
Supports Gemini, Claude, GPT, Grok, DeepSeek, Llama, and more.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
{/* Model Selector */}
|
||||||
|
<div className="mb-4">
|
||||||
|
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">AI Model</label>
|
||||||
|
<ModelSelector
|
||||||
|
selectedModel={selectedModel}
|
||||||
|
onModelChange={handleModelChange}
|
||||||
|
apiKeyConfigured={true}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
<input
|
<input
|
||||||
type="password"
|
type="password"
|
||||||
value={key}
|
value={key}
|
||||||
onChange={(e) => setKey(e.target.value)}
|
onChange={(e) => setKey(e.target.value)}
|
||||||
placeholder="AIzaSy..."
|
placeholder="sk-or-v1-..."
|
||||||
className="w-full p-2 border rounded dark:bg-gray-700 dark:border-gray-600"
|
className="w-full p-2 border rounded dark:bg-gray-700 dark:border-gray-600"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { AVAILABLE_MODELS, type ModelId } from "@/lib/openrouter";
|
||||||
|
import { ChevronDown } from "lucide-react";
|
||||||
|
import { useState, useRef, useEffect } from "react";
|
||||||
|
|
||||||
|
interface ModelSelectorProps {
|
||||||
|
selectedModel: ModelId;
|
||||||
|
onModelChange: (model: ModelId) => void;
|
||||||
|
apiKeyConfigured: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ModelSelector({
|
||||||
|
selectedModel,
|
||||||
|
onModelChange,
|
||||||
|
apiKeyConfigured,
|
||||||
|
}: ModelSelectorProps) {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// Close dropdown on outside click
|
||||||
|
useEffect(() => {
|
||||||
|
function handleClickOutside(e: MouseEvent) {
|
||||||
|
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
|
||||||
|
setIsOpen(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener("mousedown", handleClickOutside);
|
||||||
|
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const currentModel = AVAILABLE_MODELS.find((m) => m.id === selectedModel);
|
||||||
|
const groupedModels = groupBy(AVAILABLE_MODELS, (m) => m.provider);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative" ref={dropdownRef}>
|
||||||
|
<button
|
||||||
|
onClick={() => setIsOpen(!isOpen)}
|
||||||
|
disabled={!apiKeyConfigured}
|
||||||
|
className="flex items-center gap-2 px-3 py-1.5 text-xs bg-gray-100 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-md hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
title={
|
||||||
|
apiKeyConfigured
|
||||||
|
? `Model: ${currentModel?.name ?? selectedModel}`
|
||||||
|
: "Add API key first"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
{currentModel && (
|
||||||
|
<span className="w-1.5 h-1.5 rounded-full bg-green-500" />
|
||||||
|
)}
|
||||||
|
<span className="font-medium">
|
||||||
|
{currentModel?.name ?? selectedModel}
|
||||||
|
</span>
|
||||||
|
<span className="text-gray-400 hidden sm:inline">
|
||||||
|
{currentModel?.provider}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<ChevronDown size={14} className="text-gray-400" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{isOpen && (
|
||||||
|
<div className="absolute right-0 mt-1 w-64 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-xl z-50 max-h-80 overflow-y-auto">
|
||||||
|
{Object.entries(groupedModels).map(([provider, models]) => (
|
||||||
|
<div key={provider}>
|
||||||
|
<div className="px-3 py-1.5 text-xs font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-wider bg-gray-50 dark:bg-gray-900/50 border-b border-gray-100 dark:border-gray-700">
|
||||||
|
{provider}
|
||||||
|
</div>
|
||||||
|
{models.map((model) => (
|
||||||
|
<button
|
||||||
|
key={model.id}
|
||||||
|
onClick={() => {
|
||||||
|
onModelChange(model.id);
|
||||||
|
setIsOpen(false);
|
||||||
|
}}
|
||||||
|
className={`w-full text-left px-3 py-2 text-sm hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors flex items-center justify-between ${
|
||||||
|
selectedModel === model.id
|
||||||
|
? "bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300"
|
||||||
|
: ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="font-medium">{model.name}</span>
|
||||||
|
<span
|
||||||
|
className={`text-xs ${
|
||||||
|
selectedModel === model.id
|
||||||
|
? "text-blue-500"
|
||||||
|
: "text-gray-400"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{selectedModel === model.id ? "✓" : model.provider}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Utility: group array by key
|
||||||
|
function groupBy<T>(arr: readonly T[], keyFn: (item: T) => string): Record<string, T[]> {
|
||||||
|
const result: Record<string, T[]> = {};
|
||||||
|
for (const item of arr) {
|
||||||
|
const key = keyFn(item);
|
||||||
|
if (!result[key]) result[key] = [];
|
||||||
|
result[key].push(item);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
+219
-93
@@ -1,17 +1,16 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { FormEvent, useCallback, useEffect, useRef, useState } from "react";
|
import { FormEvent, useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { ChatSession } from "@google/generative-ai";
|
|
||||||
import { Chess, Move } from "chess.js";
|
import { Chess, Move } from "chess.js";
|
||||||
|
|
||||||
import { useDebug } from "@/contexts/DebugContext";
|
import { useDebug } from "@/contexts/DebugContext";
|
||||||
import { buildAutomaticAnalysisPrompt, buildTeachingPrompt } from "@/lib/analysisPrompts";
|
import { buildAutomaticAnalysisPrompt, buildTeachingPrompt } from "@/lib/analysisPrompts";
|
||||||
import { getGenAIModel } from "@/lib/gemini";
|
|
||||||
import { SupportedLanguage } from "@/lib/i18n/translations";
|
import { SupportedLanguage } from "@/lib/i18n/translations";
|
||||||
import { OpeningMetadata } from "@/lib/openings";
|
import { OpeningMetadata } from "@/lib/openings";
|
||||||
import { Personality } from "@/lib/personalities";
|
import { Personality } from "@/lib/personalities";
|
||||||
import { Stockfish, StockfishEvaluation } from "@/lib/stockfish";
|
import { Stockfish, StockfishEvaluation } from "@/lib/stockfish";
|
||||||
import { DetectedTactic } from "@/lib/tacticDetection";
|
import { DetectedTactic } from "@/lib/tacticDetection";
|
||||||
|
import type { ModelId } from "@/lib/openrouter";
|
||||||
|
|
||||||
export interface TutorMessage {
|
export interface TutorMessage {
|
||||||
role: "user" | "model";
|
role: "user" | "model";
|
||||||
@@ -21,6 +20,7 @@ export interface TutorMessage {
|
|||||||
|
|
||||||
interface UseTutorChatArgs {
|
interface UseTutorChatArgs {
|
||||||
apiKey: string | null;
|
apiKey: string | null;
|
||||||
|
modelName?: ModelId;
|
||||||
computerMove: Move | null;
|
computerMove: Move | null;
|
||||||
currentFen: string;
|
currentFen: string;
|
||||||
evalP0: StockfishEvaluation | null;
|
evalP0: StockfishEvaluation | null;
|
||||||
@@ -37,8 +37,44 @@ interface UseTutorChatArgs {
|
|||||||
userMove: Move | null;
|
userMove: Move | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Build the system prompt locally (same text as before)
|
||||||
|
function buildSystemPrompt(
|
||||||
|
personality: Personality,
|
||||||
|
playerColorName: string,
|
||||||
|
tutorColorName: string,
|
||||||
|
language: SupportedLanguage,
|
||||||
|
): string {
|
||||||
|
return `
|
||||||
|
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}
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
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.
|
||||||
|
- When the User asks for hints, ALWAYS provide helpful guidance.
|
||||||
|
- Giving hints is NOT betraying your role as opponent — it's your purpose.
|
||||||
|
|
||||||
|
CRITICAL RULES:
|
||||||
|
- You are NOT an AI assistant. You ARE the player AND the tutor.
|
||||||
|
- NEVER mention "Stockfish", "engine", "computer", "machine", or "AI".
|
||||||
|
- When asked for hints or best moves, ALWAYS help.
|
||||||
|
- Be concise but engaging. Vary your responses.
|
||||||
|
- You MUST respond in: ${language.toUpperCase()}.
|
||||||
|
`.trim();
|
||||||
|
}
|
||||||
|
|
||||||
export function useTutorChat({
|
export function useTutorChat({
|
||||||
apiKey,
|
apiKey,
|
||||||
|
modelName,
|
||||||
computerMove,
|
computerMove,
|
||||||
currentFen,
|
currentFen,
|
||||||
evalP0,
|
evalP0,
|
||||||
@@ -57,52 +93,169 @@ export function useTutorChat({
|
|||||||
const [messages, setMessages] = useState<TutorMessage[]>([]);
|
const [messages, setMessages] = useState<TutorMessage[]>([]);
|
||||||
const [input, setInput] = useState("");
|
const [input, setInput] = useState("");
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [chatSession, setChatSession] = useState<ChatSession | null>(null);
|
const [chatHistory, setChatHistory] = useState<
|
||||||
|
{ role: "user" | "model"; text: string }[]
|
||||||
|
>([]);
|
||||||
const messagesContainerRef = useRef<HTMLDivElement>(null);
|
const messagesContainerRef = useRef<HTMLDivElement>(null);
|
||||||
const lastAnalyzedMoveRef = useRef<string | null>(null);
|
const lastAnalyzedMoveRef = useRef<string | null>(null);
|
||||||
const { addEntry } = useDebug();
|
const { addEntry } = useDebug();
|
||||||
|
const isInitialized = useRef(false);
|
||||||
|
|
||||||
const tutorColor = playerColor === "white" ? "black" : "white";
|
const tutorColor = playerColor === "white" ? "black" : "white";
|
||||||
const playerColorName = playerColor === "white" ? "White" : "Black";
|
const playerColorName = playerColor === "white" ? "White" : "Black";
|
||||||
const tutorColorName = tutorColor === "white" ? "White" : "Black";
|
const tutorColorName = tutorColor === "white" ? "White" : "Black";
|
||||||
|
|
||||||
const evaluateCurrentPosition = useCallback(async () => {
|
const evaluateCurrentPosition = useCallback(async () => {
|
||||||
if (!stockfish) {
|
if (!stockfish) return null;
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return await stockfish.evaluate(game.fen(), 15);
|
return await stockfish.evaluate(game.fen(), 15);
|
||||||
} catch (error) {
|
} catch {
|
||||||
console.error("Error evaluating position:", error);
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}, [game, stockfish]);
|
}, [game, stockfish]);
|
||||||
|
|
||||||
const sendMessageToChat = useCallback(async (text: string, isSystemMessage = false) => {
|
const model = modelName ?? "google/gemini-2.5-flash";
|
||||||
if (!chatSession) return;
|
|
||||||
|
// Send message via our API route (OpenRouter-backed)
|
||||||
|
const sendMessageViaAPI = useCallback(
|
||||||
|
async (text: string, systemPrompt: string) => {
|
||||||
|
if (!apiKey) return null;
|
||||||
|
|
||||||
|
const res = await fetch("/api/v1/llm/chat", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
apiKey,
|
||||||
|
personalityId: personality.id,
|
||||||
|
language,
|
||||||
|
playerColor,
|
||||||
|
message: text,
|
||||||
|
modelName: model,
|
||||||
|
context: {
|
||||||
|
fen: currentFen,
|
||||||
|
pgn: game.pgn(),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.text().catch(() => "");
|
||||||
|
throw new Error(`API error (${res.status}): ${err.slice(0, 200)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
return data.reply as string;
|
||||||
|
},
|
||||||
|
[apiKey, language, model, personality.id, playerColor, currentFen, game],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Initialize chat — get greeting
|
||||||
|
useEffect(() => {
|
||||||
|
if (!apiKey || isInitialized.current) return;
|
||||||
|
isInitialized.current = true;
|
||||||
|
|
||||||
|
const systemPrompt = buildSystemPrompt(
|
||||||
|
personality,
|
||||||
|
playerColorName,
|
||||||
|
tutorColorName,
|
||||||
|
language,
|
||||||
|
);
|
||||||
|
|
||||||
|
const initChat = async () => {
|
||||||
|
try {
|
||||||
|
const reply = await sendMessageViaAPI(
|
||||||
|
`Introduce yourself briefly to start our game in ${language}.`,
|
||||||
|
systemPrompt,
|
||||||
|
);
|
||||||
|
if (reply) {
|
||||||
|
setMessages([
|
||||||
|
{ role: "model", text: reply, timestamp: Date.now() },
|
||||||
|
]);
|
||||||
|
setChatHistory([{ role: "model", text: reply }]);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to initialize chat:", err);
|
||||||
|
const fallback = `Hello! I am ${personality.name}. Let's play!`;
|
||||||
|
setMessages([
|
||||||
|
{ role: "model", text: fallback, timestamp: Date.now() },
|
||||||
|
]);
|
||||||
|
setChatHistory([{ role: "model", text: fallback }]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
initChat();
|
||||||
|
}, [
|
||||||
|
apiKey,
|
||||||
|
language,
|
||||||
|
model,
|
||||||
|
personality,
|
||||||
|
playerColorName,
|
||||||
|
tutorColorName,
|
||||||
|
sendMessageViaAPI,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const sendMessageToChat = useCallback(
|
||||||
|
async (text: string, isSystemMessage = false) => {
|
||||||
|
if (!apiKey) return;
|
||||||
|
|
||||||
if (!isSystemMessage) {
|
if (!isSystemMessage) {
|
||||||
setMessages((previous) => [...previous, { role: "user", text, timestamp: Date.now() }]);
|
setMessages((prev) => [
|
||||||
|
...prev,
|
||||||
|
{ role: "user", text, timestamp: Date.now() },
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const evaluation = isSystemMessage ? null : await evaluateCurrentPosition();
|
const evaluation = isSystemMessage
|
||||||
|
? null
|
||||||
|
: await evaluateCurrentPosition();
|
||||||
const finalPrompt = isSystemMessage
|
const finalPrompt = isSystemMessage
|
||||||
? text
|
? text
|
||||||
: buildTeachingPrompt(text, currentFen, evaluation, openingData, language);
|
: buildTeachingPrompt(
|
||||||
|
text,
|
||||||
|
currentFen,
|
||||||
|
evaluation,
|
||||||
|
openingData,
|
||||||
|
language,
|
||||||
|
);
|
||||||
|
|
||||||
const result = await chatSession.sendMessage(finalPrompt);
|
const systemPrompt = buildSystemPrompt(
|
||||||
const responseText = (await result.response).text();
|
personality,
|
||||||
|
playerColorName,
|
||||||
|
tutorColorName,
|
||||||
|
language,
|
||||||
|
);
|
||||||
|
|
||||||
|
const responseText = await sendMessageViaAPI(
|
||||||
|
finalPrompt,
|
||||||
|
systemPrompt,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (responseText) {
|
||||||
|
setMessages((prev) => [
|
||||||
|
...prev,
|
||||||
|
{
|
||||||
|
role: "model",
|
||||||
|
text: responseText,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
setChatHistory((prev) => [
|
||||||
|
...prev,
|
||||||
|
{ role: "user", text: finalPrompt },
|
||||||
|
{ role: "model", text: responseText },
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
setMessages((previous) => [...previous, { role: "model", text: responseText, timestamp: Date.now() }]);
|
|
||||||
addEntry({
|
addEntry({
|
||||||
type: "tutor",
|
type: "tutor",
|
||||||
action: isSystemMessage ? "Automatic Move Analysis" : "User Chat",
|
action: isSystemMessage
|
||||||
|
? "Automatic Move Analysis"
|
||||||
|
: "User Chat",
|
||||||
prompt: finalPrompt,
|
prompt: finalPrompt,
|
||||||
response: responseText,
|
response: responseText ?? "",
|
||||||
metadata: {
|
metadata: {
|
||||||
currentFen,
|
currentFen,
|
||||||
personality: personality.name,
|
personality: personality.name,
|
||||||
@@ -117,80 +270,27 @@ export function useTutorChat({
|
|||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
onCheckComputerMove();
|
onCheckComputerMove();
|
||||||
}
|
}
|
||||||
}, [addEntry, chatSession, computerMove?.san, currentFen, evaluateCurrentPosition, language, onCheckComputerMove, openingData, personality.name, userMove?.san]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!apiKey) {
|
|
||||||
setChatSession(null);
|
|
||||||
setMessages([]);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const model = getGenAIModel(apiKey, "gemini-2.5-flash");
|
|
||||||
const session = model.startChat({
|
|
||||||
history: [
|
|
||||||
{
|
|
||||||
role: "user",
|
|
||||||
parts: [{
|
|
||||||
text: `
|
|
||||||
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}
|
|
||||||
|
|
||||||
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".
|
|
||||||
- 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.
|
|
||||||
- You MUST respond in the following language: ${language.toUpperCase()}.
|
|
||||||
- Translate your personality style into this language.
|
|
||||||
`,
|
|
||||||
}],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
role: "model",
|
|
||||||
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.`,
|
|
||||||
}],
|
|
||||||
},
|
},
|
||||||
|
[
|
||||||
|
addEntry,
|
||||||
|
apiKey,
|
||||||
|
computerMove?.san,
|
||||||
|
currentFen,
|
||||||
|
evaluateCurrentPosition,
|
||||||
|
language,
|
||||||
|
model,
|
||||||
|
onCheckComputerMove,
|
||||||
|
openingData,
|
||||||
|
personality,
|
||||||
|
playerColorName,
|
||||||
|
tutorColorName,
|
||||||
|
userMove?.san,
|
||||||
|
sendMessageViaAPI,
|
||||||
],
|
],
|
||||||
});
|
);
|
||||||
setChatSession(session);
|
|
||||||
|
|
||||||
session.sendMessage(`Introduce yourself briefly to start our game. Keep it short and in ${language}.`).then((result) => {
|
|
||||||
setMessages([{ role: "model", text: result.response.text(), timestamp: Date.now() }]);
|
|
||||||
}).catch((error) => {
|
|
||||||
console.error("Failed to get greeting:", error);
|
|
||||||
setMessages([{ role: "model", text: `Hello! I am ${personality.name}. Let's play!`, timestamp: Date.now() }]);
|
|
||||||
});
|
|
||||||
}, [apiKey, language, personality, playerColorName, tutorColorName]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (messagesContainerRef.current) {
|
if (!userMove || !computerMove || !evalP0 || !evalP2 || !apiKey) return;
|
||||||
messagesContainerRef.current.scrollTop = messagesContainerRef.current.scrollHeight;
|
|
||||||
}
|
|
||||||
}, [messages]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!userMove || !computerMove || !evalP0 || !evalP2 || !chatSession) return;
|
|
||||||
|
|
||||||
const exchangeKey = `${userMove.lan}-${computerMove.lan}`;
|
const exchangeKey = `${userMove.lan}-${computerMove.lan}`;
|
||||||
if (lastAnalyzedMoveRef.current === exchangeKey) return;
|
if (lastAnalyzedMoveRef.current === exchangeKey) return;
|
||||||
@@ -222,11 +322,28 @@ CRITICAL RULES:
|
|||||||
};
|
};
|
||||||
|
|
||||||
analyzeExchange();
|
analyzeExchange();
|
||||||
}, [chatSession, computerMove, currentFen, evalP0, evalP2, game, language, missedTactics, onAnalysisComplete, openingData, playerColorName, sendMessageToChat, tutorColorName, userMove]);
|
}, [
|
||||||
|
apiKey,
|
||||||
|
chatSession,
|
||||||
|
computerMove,
|
||||||
|
currentFen,
|
||||||
|
evalP0,
|
||||||
|
evalP2,
|
||||||
|
game,
|
||||||
|
language,
|
||||||
|
missedTactics,
|
||||||
|
onAnalysisComplete,
|
||||||
|
openingData,
|
||||||
|
playerColorName,
|
||||||
|
sendMessageToChat,
|
||||||
|
tutorColorName,
|
||||||
|
userMove,
|
||||||
|
]);
|
||||||
|
|
||||||
const handleSubmit = useCallback((event: FormEvent) => {
|
const handleSubmit = useCallback(
|
||||||
|
(event: FormEvent) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (!input.trim() || !chatSession) return;
|
if (!input.trim() || !apiKey) return;
|
||||||
|
|
||||||
sendMessageToChat(input);
|
sendMessageToChat(input);
|
||||||
setInput("");
|
setInput("");
|
||||||
@@ -234,7 +351,16 @@ CRITICAL RULES:
|
|||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
onCheckComputerMove();
|
onCheckComputerMove();
|
||||||
}, 100);
|
}, 100);
|
||||||
}, [chatSession, input, onCheckComputerMove, sendMessageToChat]);
|
},
|
||||||
|
[apiKey, input, onCheckComputerMove, sendMessageToChat],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (messagesContainerRef.current) {
|
||||||
|
messagesContainerRef.current.scrollTop =
|
||||||
|
messagesContainerRef.current.scrollHeight;
|
||||||
|
}
|
||||||
|
}, [messages]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
|
|||||||
@@ -0,0 +1,201 @@
|
|||||||
|
/**
|
||||||
|
* OpenRouter AI Abstraction Layer
|
||||||
|
*
|
||||||
|
* Replaces the hardcoded Google Gemini SDK with a provider-agnostic
|
||||||
|
* OpenRouter client. Supports any model available on OpenRouter
|
||||||
|
* (Gemini, Claude, GPT, Grok, DeepSeek, Llama, etc.)
|
||||||
|
*
|
||||||
|
* API: https://openrouter.ai/docs/api-reference
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ── Available Models ──────────────────────────────────────────
|
||||||
|
// Models the tutor supports. User can select any via the dropdown.
|
||||||
|
export const AVAILABLE_MODELS = [
|
||||||
|
// Google
|
||||||
|
{ id: "google/gemini-2.5-flash", name: "Gemini 2.5 Flash", provider: "Google" },
|
||||||
|
{ id: "google/gemini-2.5-pro", name: "Gemini 2.5 Pro", provider: "Google" },
|
||||||
|
// Anthropic
|
||||||
|
{ id: "anthropic/claude-sonnet-4", name: "Claude Sonnet 4", provider: "Anthropic" },
|
||||||
|
{ id: "anthropic/claude-3.5-haiku", name: "Claude 3.5 Haiku", provider: "Anthropic" },
|
||||||
|
// OpenAI
|
||||||
|
{ id: "openai/gpt-4o", name: "GPT-4o", provider: "OpenAI" },
|
||||||
|
{ id: "openai/gpt-4o-mini", name: "GPT-4o Mini", provider: "OpenAI" },
|
||||||
|
// DeepSeek
|
||||||
|
{ id: "deepseek/deepseek-chat", name: "DeepSeek Chat", provider: "DeepSeek" },
|
||||||
|
// xAI
|
||||||
|
{ id: "x-ai/grok-3", name: "Grok 3", provider: "xAI" },
|
||||||
|
// Meta
|
||||||
|
{ id: "meta-llama/llama-3.1-70b", name: "Llama 3.1 70B", provider: "Meta" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type ModelInfo = (typeof AVAILABLE_MODELS)[number];
|
||||||
|
export type ModelId = ModelInfo["id"];
|
||||||
|
|
||||||
|
const DEFAULT_MODEL: ModelId = "google/gemini-2.5-flash";
|
||||||
|
|
||||||
|
// ── Function Calling Schema ───────────────────────────────────
|
||||||
|
export const EVALUATE_POSITION_TOOL = {
|
||||||
|
type: "function" as const,
|
||||||
|
function: {
|
||||||
|
name: "evaluate_position",
|
||||||
|
description:
|
||||||
|
"Evaluates a chess position using the Stockfish engine to get the best move and score.",
|
||||||
|
parameters: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
fen: {
|
||||||
|
type: "string",
|
||||||
|
description: "The FEN string of the position to evaluate.",
|
||||||
|
},
|
||||||
|
depth: {
|
||||||
|
type: "integer",
|
||||||
|
description: "The search depth for the engine (default 15).",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ["fen"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── OpenRouter Client ─────────────────────────────────────────
|
||||||
|
const OPENROUTER_BASE = "https://openrouter.ai/api/v1";
|
||||||
|
|
||||||
|
interface ChatMessage {
|
||||||
|
role: "system" | "user" | "assistant";
|
||||||
|
content: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OpenRouterResponse {
|
||||||
|
id: string;
|
||||||
|
choices: {
|
||||||
|
message: {
|
||||||
|
role: string;
|
||||||
|
content: string;
|
||||||
|
tool_calls?: unknown[];
|
||||||
|
};
|
||||||
|
finish_reason: string;
|
||||||
|
}[];
|
||||||
|
usage?: {
|
||||||
|
prompt_tokens: number;
|
||||||
|
completion_tokens: number;
|
||||||
|
total_tokens: number;
|
||||||
|
};
|
||||||
|
error?: { message: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends a chat completion request to OpenRouter.
|
||||||
|
* Handles function-calling with the evaluate_position tool.
|
||||||
|
*/
|
||||||
|
export async function chatCompletion(
|
||||||
|
apiKey: string,
|
||||||
|
model: ModelId,
|
||||||
|
messages: ChatMessage[],
|
||||||
|
options?: { temperature?: number; maxTokens?: number }
|
||||||
|
): Promise<{ text: string }> {
|
||||||
|
const response = await fetch(`${OPENROUTER_BASE}/chat/completions`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${apiKey}`,
|
||||||
|
"HTTP-Referer": "https://github.com/Tony_tech/chess-project",
|
||||||
|
"X-Title": "Chess Tutor",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
model,
|
||||||
|
messages,
|
||||||
|
temperature: options?.temperature ?? 0.7,
|
||||||
|
max_tokens: options?.maxTokens ?? 4096,
|
||||||
|
tools: [EVALUATE_POSITION_TOOL],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorBody = await response.text().catch(() => "Unknown error");
|
||||||
|
throw new Error(
|
||||||
|
`OpenRouter API error (${response.status}): ${errorBody.slice(0, 300)}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data: OpenRouterResponse = await response.json();
|
||||||
|
|
||||||
|
if (data.error) {
|
||||||
|
throw new Error(`OpenRouter error: ${data.error.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const choice = data.choices?.[0];
|
||||||
|
if (!choice?.message?.content) {
|
||||||
|
// Handle function-call-only responses
|
||||||
|
if (choice?.message?.tool_calls?.length) {
|
||||||
|
return { text: "[Function call made]" };
|
||||||
|
}
|
||||||
|
return { text: "" };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { text: choice.message.content };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends a chat completion with conversation history.
|
||||||
|
* Used by the tutor chat endpoint.
|
||||||
|
*/
|
||||||
|
export async function tutorChat(
|
||||||
|
apiKey: string,
|
||||||
|
model: ModelId,
|
||||||
|
systemPrompt: string,
|
||||||
|
history: { role: "user" | "assistant"; content: string }[],
|
||||||
|
userMessage: string
|
||||||
|
): Promise<{ text: string }> {
|
||||||
|
const messages: ChatMessage[] = [
|
||||||
|
{ role: "system", content: systemPrompt },
|
||||||
|
...history.map((m) => ({
|
||||||
|
role: m.role === "assistant" ? ("assistant" as const) : ("user" as const),
|
||||||
|
content: m.content,
|
||||||
|
})),
|
||||||
|
{ role: "user", content: userMessage },
|
||||||
|
];
|
||||||
|
|
||||||
|
return chatCompletion(apiKey, model, messages);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets a simple single-turn completion (for opening explanations etc.)
|
||||||
|
*/
|
||||||
|
export async function simpleCompletion(
|
||||||
|
apiKey: string,
|
||||||
|
model: ModelId,
|
||||||
|
systemPrompt: string,
|
||||||
|
prompt: string,
|
||||||
|
options?: { temperature?: number; maxTokens?: number }
|
||||||
|
): Promise<{ text: string }> {
|
||||||
|
const messages: ChatMessage[] = [
|
||||||
|
{ role: "system", content: systemPrompt },
|
||||||
|
{ role: "user", content: prompt },
|
||||||
|
];
|
||||||
|
|
||||||
|
const response = await fetch(`${OPENROUTER_BASE}/chat/completions`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${apiKey}`,
|
||||||
|
"HTTP-Referer": "https://github.com/Tony_tech/chess-project",
|
||||||
|
"X-Title": "Chess Tutor",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
model,
|
||||||
|
messages,
|
||||||
|
temperature: options?.temperature ?? 0.7,
|
||||||
|
max_tokens: options?.maxTokens ?? 1024,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorBody = await response.text().catch(() => "");
|
||||||
|
throw new Error(`OpenRouter error (${response.status}): ${errorBody.slice(0, 300)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data: OpenRouterResponse = await response.json();
|
||||||
|
return { text: data.choices?.[0]?.message?.content ?? "" };
|
||||||
|
}
|
||||||
|
|
||||||
|
export { DEFAULT_MODEL };
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
# Sprint 1 — Chess Tutor: Local Setup + OpenRouter + Playable Board
|
||||||
|
|
||||||
|
**Goal:** Get the chess tutor running locally with a playable board, AI coach via OpenRouter, and model selection.
|
||||||
|
|
||||||
|
## Now
|
||||||
|
|
||||||
|
- [x] Merge chess_tutor codebase into Tony_tech/chess-project
|
||||||
|
- [x] Preserve Hermes agent files (agents/, .gitea/, AGENTS.md)
|
||||||
|
- [x] Add OpenRouter abstraction layer (`src/lib/openrouter.ts`)
|
||||||
|
- [x] Create model selector dropdown (`src/components/ModelSelector.tsx`)
|
||||||
|
- [x] Update API routes to use OpenRouter (chat + opening explanation)
|
||||||
|
- [x] Update APIKeyInput for OpenRouter keys
|
||||||
|
- [ ] Install dependencies and start dev server
|
||||||
|
- [ ] Verify board renders at localhost:3050
|
||||||
|
- [ ] Test AI chat with an OpenRouter model
|
||||||
|
- [ ] Test AI chat with a different model (switch via dropdown)
|
||||||
|
|
||||||
|
## Next
|
||||||
|
|
||||||
|
- [ ] Add model selection persistence across sessions
|
||||||
|
- [ ] Verify stockfish engine works in browser
|
||||||
|
- [ ] Test opening trainer functionality
|
||||||
|
- [ ] Test tactical puzzle mode
|
||||||
|
- [ ] Add "Chess Coach" personality presets page
|
||||||
|
|
||||||
|
## Backlog
|
||||||
|
|
||||||
|
- [ ] Add saved game management UI improvements
|
||||||
|
- [ ] Add PGN import/export
|
||||||
|
- [ ] Add game analysis mode (AI reviews your game)
|
||||||
|
- [ ] Multi-language support polish
|
||||||
|
- [ ] Performance optimization for mobile
|
||||||
|
- [ ] Docker setup for easy deployment
|
||||||
|
|
||||||
|
## Setup Notes
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Install dependencies
|
||||||
|
cd Tony_tech/chess-project
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# 2. Start dev server (default port 3050)
|
||||||
|
npm run dev
|
||||||
|
|
||||||
|
# 3. Open browser to http://localhost:3050
|
||||||
|
|
||||||
|
# 4. On first visit, the onboarding page will prompt for:
|
||||||
|
# - Language selection
|
||||||
|
# - OpenRouter API key (get one at https://openrouter.ai/keys)
|
||||||
|
# - Model selection (pick your preferred AI model)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Env Configuration
|
||||||
|
|
||||||
|
Create `chess-project/.env.local`:
|
||||||
|
```
|
||||||
|
OPENROUTER_API_KEY=sk-or-v1-your-key-here
|
||||||
|
NEXT_PUBLIC_DEFAULT_MODEL=google/gemini-2.5-flash
|
||||||
|
```
|
||||||
|
|
||||||
|
If `OPENROUTER_API_KEY` is set as an env var, the opening explanation endpoint uses it automatically. For the tutor chat, users enter their key through the UI (stored in localStorage).
|
||||||
|
|
||||||
|
### Available Models
|
||||||
|
|
||||||
|
Users can switch between any of these in the model selector dropdown:
|
||||||
|
|
||||||
|
| Model | Provider | Cost |
|
||||||
|
|-------|----------|------|
|
||||||
|
| Gemini 2.5 Flash | Google | Low |
|
||||||
|
| Gemini 2.5 Pro | Google | Medium |
|
||||||
|
| Claude Sonnet 4 | Anthropic | Medium |
|
||||||
|
| Claude 3.5 Haiku | Anthropic | Low |
|
||||||
|
| GPT-4o | OpenAI | Medium |
|
||||||
|
| GPT-4o Mini | OpenAI | Low |
|
||||||
|
| DeepSeek Chat | DeepSeek | Very Low |
|
||||||
|
| Grok 3 | xAI | Medium |
|
||||||
|
| Llama 3.1 70B | Meta | Low |
|
||||||
|
|
||||||
|
### Port
|
||||||
|
|
||||||
|
The dev server runs on port **3050** by default (configured in package.json).
|
||||||
Reference in New Issue
Block a user