mirror of
https://github.com/R0m1k3/chesspreview.git
synced 2026-10-18 12:48:41 +02:00
Build Atelier 64 chess training app
This commit is contained in:
commit
3dd0aa9a09
15 files changed
+2574
No files matched your search
@@ -0,0 +1,56 @@
|
||||
import { spawn } from 'node:child_process';
|
||||
|
||||
export function parseInfo(line) {
|
||||
if (!line.startsWith('info ') || !line.includes(' pv ')) return null;
|
||||
const depth = Number(line.match(/\bdepth (\d+)/)?.[1] || 0);
|
||||
const multipv = Number(line.match(/\bmultipv (\d+)/)?.[1] || 1);
|
||||
const scoreMatch = line.match(/\bscore (cp|mate) (-?\d+)/);
|
||||
const pv = line.split(' pv ')[1]?.trim().split(/\s+/) || [];
|
||||
if (!scoreMatch || !pv.length) return null;
|
||||
return {
|
||||
depth,
|
||||
multipv,
|
||||
score: { type: scoreMatch[1], value: Number(scoreMatch[2]) },
|
||||
pv,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeOllamaUrl(value) {
|
||||
const url = new URL(value);
|
||||
if (!['http:', 'https:'].includes(url.protocol)) throw new Error('Adresse Ollama invalide');
|
||||
return url.origin + url.pathname.replace(/\/$/, '');
|
||||
}
|
||||
|
||||
export function analyzePosition(fen, depth = 16, multiPv = 3) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const binary = process.env.STOCKFISH_PATH || (process.platform === 'win32' ? 'stockfish' : '/usr/games/stockfish');
|
||||
const engine = spawn(binary, [], { stdio: ['pipe', 'pipe', 'pipe'] });
|
||||
const lines = new Map();
|
||||
let buffer = '';
|
||||
const timeout = setTimeout(() => finish(new Error('Analyse Stockfish expirée')), 25000);
|
||||
|
||||
function finish(error) {
|
||||
clearTimeout(timeout);
|
||||
if (!engine.killed) engine.kill();
|
||||
if (error) reject(error);
|
||||
else resolve([...lines.values()].sort((a, b) => a.multipv - b.multipv));
|
||||
}
|
||||
|
||||
engine.on('error', finish);
|
||||
engine.stderr.on('data', () => {});
|
||||
engine.stdout.on('data', (chunk) => {
|
||||
buffer += chunk.toString();
|
||||
const rows = buffer.split(/\r?\n/);
|
||||
buffer = rows.pop() || '';
|
||||
for (const row of rows) {
|
||||
const parsed = parseInfo(row);
|
||||
if (parsed && parsed.depth >= depth - 1) lines.set(parsed.multipv, parsed);
|
||||
if (row.startsWith('bestmove')) finish(lines.size ? null : new Error('Stockfish sans résultat'));
|
||||
}
|
||||
});
|
||||
engine.stdin.write('uci\n');
|
||||
engine.stdin.write(`setoption name MultiPV value ${multiPv}\n`);
|
||||
engine.stdin.write(`position fen ${fen}\n`);
|
||||
engine.stdin.write(`go depth ${depth}\n`);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { normalizeOllamaUrl, parseInfo } from './engine.js';
|
||||
|
||||
test('parse une ligne MultiPV Stockfish', () => {
|
||||
assert.deepEqual(parseInfo('info depth 16 multipv 2 score cp -34 nodes 4 pv e2e4 e7e5'), {
|
||||
depth: 16, multipv: 2, score: { type: 'cp', value: -34 }, pv: ['e2e4', 'e7e5'],
|
||||
});
|
||||
});
|
||||
|
||||
test('normalise URL Ollama', () => {
|
||||
assert.equal(normalizeOllamaUrl('http://192.168.1.3:11434/'), 'http://192.168.1.3:11434');
|
||||
assert.throws(() => normalizeOllamaUrl('file:///etc/passwd'));
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
import express from 'express';
|
||||
import { Chess } from 'chess.js';
|
||||
import { analyzePosition, normalizeOllamaUrl } from './engine.js';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const app = express();
|
||||
const port = Number(process.env.PORT || 3000);
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
app.use(express.json({ limit: '32kb' }));
|
||||
|
||||
app.get('/api/health', (_req, res) => res.json({ ok: true }));
|
||||
|
||||
app.post('/api/analyze', async (req, res) => {
|
||||
try {
|
||||
const chess = new Chess(req.body.fen);
|
||||
if (chess.isGameOver()) return res.json({ lines: [], gameOver: true });
|
||||
const depth = Math.max(8, Math.min(22, Number(req.body.depth) || 16));
|
||||
const raw = await analyzePosition(chess.fen(), depth, 3);
|
||||
const lines = raw.map((line) => {
|
||||
const replay = new Chess(chess.fen());
|
||||
const moves = [];
|
||||
for (const uci of line.pv.slice(0, 8)) {
|
||||
const move = replay.move({ from: uci.slice(0, 2), to: uci.slice(2, 4), promotion: uci[4] || 'q' });
|
||||
if (!move) break;
|
||||
moves.push({ uci, san: move.san });
|
||||
}
|
||||
return { ...line, moves };
|
||||
});
|
||||
res.json({ lines, gameOver: false });
|
||||
} catch (error) {
|
||||
res.status(400).json({ error: error.message || 'Analyse impossible' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/ollama/test', async (req, res) => {
|
||||
try {
|
||||
const base = normalizeOllamaUrl(req.body.url);
|
||||
const response = await fetch(`${base}/api/tags`, { signal: AbortSignal.timeout(7000) });
|
||||
if (!response.ok) throw new Error(`Ollama répond ${response.status}`);
|
||||
const data = await response.json();
|
||||
res.json({ ok: true, models: (data.models || []).map((m) => m.name) });
|
||||
} catch (error) {
|
||||
res.status(502).json({ error: error.message || 'Connexion Ollama impossible' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/explain', async (req, res) => {
|
||||
try {
|
||||
const base = normalizeOllamaUrl(req.body.url);
|
||||
const { fen, lines, model = 'qwen3:8b', lastMove } = req.body;
|
||||
new Chess(fen);
|
||||
const prompt = `Tu es un entraîneur d'échecs francophone précis et encourageant. Position FEN: ${fen}. Dernier coup: ${lastMove || 'début de position'}. Variantes Stockfish: ${JSON.stringify(lines)}. Explique en français le meilleur coup, son idée tactique ou stratégique, la menace, et un piège à éviter. 120 mots maximum. N'invente aucune variante hors des données. Utilise texte simple, sans markdown.`;
|
||||
const response = await fetch(`${base}/api/chat`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ model, stream: false, messages: [{ role: 'user', content: prompt }], options: { temperature: 0.25 } }),
|
||||
signal: AbortSignal.timeout(90000),
|
||||
});
|
||||
if (!response.ok) throw new Error(`Ollama répond ${response.status}`);
|
||||
const data = await response.json();
|
||||
res.json({ explanation: data.message?.content?.replace(/<think>[\s\S]*?<\/think>/g, '').trim() || 'Aucune explication reçue.' });
|
||||
} catch (error) {
|
||||
res.status(502).json({ error: error.message || 'Explication impossible' });
|
||||
}
|
||||
});
|
||||
|
||||
app.use(express.static(path.join(root, 'dist')));
|
||||
app.use((_req, res) => res.sendFile(path.join(root, 'dist', 'index.html')));
|
||||
|
||||
if (process.argv[1] === fileURLToPath(import.meta.url)) {
|
||||
app.listen(port, () => console.log(`Atelier 64 écoute sur :${port}`));
|
||||
}
|
||||
|
||||
export default app;
|
||||
@@ -0,0 +1,19 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { once } from 'node:events';
|
||||
import app from './index.js';
|
||||
|
||||
test('expose état API et frontend compilé', async (t) => {
|
||||
const server = app.listen(0, '127.0.0.1');
|
||||
t.after(() => server.close());
|
||||
await once(server, 'listening');
|
||||
const { port } = server.address();
|
||||
|
||||
const health = await fetch(`http://127.0.0.1:${port}/api/health`);
|
||||
assert.equal(health.status, 200);
|
||||
assert.deepEqual(await health.json(), { ok: true });
|
||||
|
||||
const page = await fetch(`http://127.0.0.1:${port}/`);
|
||||
assert.equal(page.status, 200);
|
||||
assert.match(await page.text(), /Atelier 64/);
|
||||
});
|
||||
Reference in new issue
Block a user