Project Files
src / web.ts
import * as http from "node:http";
import { spawn } from "node:child_process";
import { networkInterfaces } from "node:os";
import type { AddressInfo } from "node:net";
import {
createSession,
forgetSession,
getOrResume,
getSessionProgress,
runTurnStreaming,
type SessionConfig,
type StreamEvent,
} from "./session";
import {
filterByNotion,
formatCorpusBlock,
loadCorpus,
loadSystemPrompt,
} from "./corpus";
import { loadSession as loadFromDisk, setStorageDir, listSessions } from "./storage";
import { buildAdminListPage, buildAdminSessionPage } from "./admin";
import { buildPedagogiePage } from "./pedagogie";
import { buildCatalogPage } from "./catalog";
import { buildCorpusPage } from "./corpus-page";
import {
listSujets,
addSujet,
deleteSujet,
classifySujet,
findSimilarSujet,
} from "./sujets";
import {
searchWikisource,
fetchWikisourcePage,
extractCandidates,
classifyCitationNotions,
addUserCitation,
deleteUserCitation,
addSourceToCitation,
verifyQuoteAtUrl,
computeNotionStats,
} from "./corpus-builder";
import { loadAllCitationsForCatalog } from "./corpus";
type ServerHandle = {
url: string;
lanUrls: string[];
close: () => Promise<void>;
};
let active: ServerHandle | null = null;
const HEARTBEAT_TIMEOUT_MS = 30 * 1000; // un browser silencieux >30s = considéré parti
const EMPTY_GRACE_MS = 60 * 1000; // 60s aprĂšs le dernier browser disparu â shutdown
const WATCHDOG_INTERVAL_MS = 5 * 1000;
const FALLBACK_IDLE_MS = 60 * 60 * 1000; // garde-fou : 1h sans aucune activité, on coupe
export function getActiveServer(): ServerHandle | null { return active; }
export async function startWebServer(args: {
defaultNotion: string;
defaultSujet: string;
cfg: SessionConfig;
bindHost: string;
port: number;
openBrowser: boolean;
openPath?: string; // path relatif Ă l'URL racine, ex "/" ou "/play?notion=X&sujet=Y"
storageDir?: string;
}): Promise<ServerHandle> {
if (active) await active.close();
if (args.storageDir) setStorageDir(args.storageDir);
const corpus = loadCorpus();
const promptTpl = loadSystemPrompt();
function buildSystemPrompt(notion: string, sujet: string): { prompt: string; filtered: typeof corpus } {
const filtered = filterByNotion(corpus, notion);
const block = formatCorpusBlock(filtered);
return {
prompt: promptTpl.replace("{{SUJET}}", sujet).replace("{{CITATIONS}}", block),
filtered,
};
}
// Présence : Map<browserToken, lastSeen>. Chaque onglet ouvert tient un token.
const browsers = new Map<string, number>();
let emptySince: number | null = null;
let lastAnyActivity = Date.now();
const touchActivity = () => { lastAnyActivity = Date.now(); };
const watchdog = setInterval(() => {
const now = Date.now();
// Expire les browsers silencieux
for (const [tok, ts] of browsers) {
if (now - ts > HEARTBEAT_TIMEOUT_MS) browsers.delete(tok);
}
if (browsers.size === 0) {
if (emptySince == null) emptySince = now;
else if (now - emptySince > EMPTY_GRACE_MS) {
if (active) active.close().catch(() => {});
}
} else {
emptySince = null;
}
// Garde-fou anti-bug : si plus aucune activité HTTP depuis 1h, on coupe quoi qu'il arrive
if (now - lastAnyActivity > FALLBACK_IDLE_MS) {
if (active) active.close().catch(() => {});
}
}, WATCHDOG_INTERVAL_MS);
const server = http.createServer(async (req, res) => {
const url = req.url ?? "/";
touchActivity();
try {
if (req.method === "GET" && (url === "/" || url === "/index.html")) {
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
res.end(buildCatalogPage());
return;
}
if (req.method === "GET" && (url === "/corpus" || url === "/corpus/")) {
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
res.end(buildCorpusPage());
return;
}
// Page de jeu : /play ou /play?notion=X&sujet=Y (override depuis catalogue)
if (req.method === "GET" && (url === "/play" || url.startsWith("/play?"))) {
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
res.end(buildPlayPage({
defaultNotion: args.defaultNotion,
defaultSujet: args.defaultSujet,
}));
return;
}
if (req.method === "GET" && url === "/sessions") {
send(res, 200, { sessions: listSessions() });
return;
}
// Ătat courant d'une session : cartes consommĂ©es / restantes /
// prochaine carte attendue. Source de vĂ©ritĂ© cĂŽtĂ© serveur â la UI
// peut afficher « Carte 3/5 » sans deviner.
if (req.method === "GET" && url.startsWith("/api/sessions/") && url.endsWith("/state")) {
const id = decodeURIComponent(url.slice("/api/sessions/".length, url.length - "/state".length));
if (!/^[a-zA-Z0-9_-]{1,64}$/.test(id)) { send(res, 400, { error: "bad id" }); return; }
// S'assure que la session est résidente en mémoire pour calculer
// sur les messages complets (sinon getSessionProgress retourne null).
const loaded = loadFromDisk(id);
if (!loaded) { send(res, 404, { error: "session not found" }); return; }
const { filtered } = buildSystemPrompt(loaded.meta.notion, loaded.meta.sujet);
getOrResume({ id, corpus: filtered, cfg: args.cfg });
const progress = getSessionProgress(id);
if (!progress) { send(res, 500, { error: "could not compute progress" }); return; }
send(res, 200, progress);
return;
}
// ----- API catalogue de sujets -----
if (req.method === "GET" && (url === "/api/sujets" || url === "/api/sujets/")) {
send(res, 200, { sujets: listSujets() });
return;
}
if (req.method === "POST" && url === "/api/sujets") {
const body = await readBody(req, 4 * 1024);
try {
const parsed = JSON.parse(body || "{}") as { notion?: string; sujet?: string };
if (!parsed.notion || !parsed.sujet) { send(res, 400, { error: "notion et sujet requis" }); return; }
const s = addSujet({ notion: parsed.notion, sujet: parsed.sujet });
send(res, 200, { sujet: s });
} catch (e) {
send(res, 400, { error: e instanceof Error ? e.message : String(e) });
}
return;
}
if (req.method === "DELETE" && url.startsWith("/api/sujets/")) {
const id = decodeURIComponent(url.slice("/api/sujets/".length));
const ok = deleteSujet(id);
send(res, ok ? 200 : 404, { deleted: ok });
return;
}
if (req.method === "POST" && url === "/api/sujets/classify") {
const body = await readBody(req, 4 * 1024);
const parsed = JSON.parse(body || "{}") as { sujet?: string };
if (!parsed.sujet) { send(res, 400, { error: "sujet requis" }); return; }
try {
const result = await classifySujet(parsed.sujet, args.cfg);
send(res, 200, result);
} catch (e) {
send(res, 500, { error: e instanceof Error ? e.message : String(e) });
}
return;
}
// ----- API corpus -----
if (req.method === "GET" && (url === "/api/corpus" || url === "/api/corpus/")) {
send(res, 200, { citations: loadAllCitationsForCatalog() });
return;
}
if (req.method === "POST" && url === "/api/corpus/search") {
const body = await readBody(req, 4 * 1024);
const parsed = JSON.parse(body || "{}") as { query?: string };
if (!parsed.query) { send(res, 400, { error: "query requis" }); return; }
try {
const hits = await searchWikisource(parsed.query, 10);
send(res, 200, { hits });
} catch (e) {
send(res, 500, { error: e instanceof Error ? e.message : String(e) });
}
return;
}
if (req.method === "POST" && url === "/api/corpus/extract") {
const body = await readBody(req, 4 * 1024);
const parsed = JSON.parse(body || "{}") as { title?: string; query?: string };
if (!parsed.title) { send(res, 400, { error: "title requis" }); return; }
try {
const page = await fetchWikisourcePage(parsed.title);
const candidates = await extractCandidates({
pageTitle: page.title,
pageText: page.text,
query: parsed.query ?? "",
cfg: args.cfg,
maxCandidates: 5,
});
send(res, 200, { candidates, sourceUrl: page.url, pageTitle: page.title });
} catch (e) {
send(res, 500, { error: e instanceof Error ? e.message : String(e) });
}
return;
}
if (req.method === "POST" && url === "/api/corpus/classify") {
const body = await readBody(req, 4 * 1024);
const parsed = JSON.parse(body || "{}") as { texte_fr?: string; auteur?: string; oeuvre?: string };
if (!parsed.texte_fr || !parsed.auteur || !parsed.oeuvre) {
send(res, 400, { error: "texte_fr, auteur, oeuvre requis" }); return;
}
try {
const notions = await classifyCitationNotions({
texte_fr: parsed.texte_fr,
auteur: parsed.auteur,
oeuvre: parsed.oeuvre,
cfg: args.cfg,
});
send(res, 200, { notions });
} catch (e) {
send(res, 500, { error: e instanceof Error ? e.message : String(e) });
}
return;
}
if (req.method === "POST" && url === "/api/corpus/add") {
const body = await readBody(req, 16 * 1024);
const parsed = JSON.parse(body || "{}") as {
texte_fr?: string; auteur?: string; oeuvre?: string;
partie?: string; reference?: string; notions?: string[];
source?: { url: string; fetched: string; kind: string; extract?: string };
};
if (!parsed.texte_fr || !parsed.auteur || !parsed.oeuvre || !parsed.source?.url) {
send(res, 400, { error: "champs requis manquants" }); return;
}
try {
const c = addUserCitation({
texte_fr: parsed.texte_fr,
auteur: parsed.auteur,
oeuvre: parsed.oeuvre,
partie: parsed.partie,
reference: parsed.reference,
notions: parsed.notions ?? [],
sources: [parsed.source],
});
send(res, 200, { citation: c });
} catch (e) {
send(res, 400, { error: e instanceof Error ? e.message : String(e) });
}
return;
}
// Vérification + ajout d'une 2e source pour une citation pending
if (req.method === "POST" && url.startsWith("/api/corpus/") && url.endsWith("/add-source")) {
const id = decodeURIComponent(url.slice("/api/corpus/".length, url.length - "/add-source".length));
const body = await readBody(req, 4 * 1024);
const parsed = JSON.parse(body || "{}") as { url?: string };
if (!parsed.url) { send(res, 400, { error: "url requis" }); return; }
const all = loadAllCitationsForCatalog();
const citation = all.find((c) => c.id === id);
if (!citation) { send(res, 404, { error: "citation introuvable" }); return; }
const forbiddenHosts: string[] = [];
for (const s of citation.sources ?? []) {
try { forbiddenHosts.push(new URL(s.url).host); } catch { /* skip */ }
}
try {
const result = await verifyQuoteAtUrl({
url: parsed.url,
quote: citation.texte_fr,
forbiddenHosts,
});
if (!result.ok) { send(res, 422, { ok: false, reason: result.reason, bestDist: result.bestDist, bestSnippet: result.bestSnippet }); return; }
const updated = addSourceToCitation(id, {
url: parsed.url,
fetched: new Date().toISOString().slice(0, 10),
kind: "user-verified",
extract: result.matchedSnippet,
});
send(res, 200, { ok: true, bestDist: result.bestDist, citation: updated });
} catch (e) {
send(res, 500, { error: e instanceof Error ? e.message : String(e) });
}
return;
}
if (req.method === "GET" && (url === "/api/notions/stats" || url === "/api/notions/stats/")) {
const stats = computeNotionStats(loadAllCitationsForCatalog());
send(res, 200, { stats });
return;
}
if (req.method === "DELETE" && url.startsWith("/api/corpus/")) {
const id = decodeURIComponent(url.slice("/api/corpus/".length));
const ok = deleteUserCitation(id);
send(res, ok ? 200 : 404, { deleted: ok });
return;
}
if (req.method === "POST" && url === "/api/sujets/similar") {
const body = await readBody(req, 4 * 1024);
const parsed = JSON.parse(body || "{}") as { sujet?: string; notion?: string };
if (!parsed.sujet || !parsed.notion) { send(res, 400, { error: "sujet et notion requis" }); return; }
try {
const result = await findSimilarSujet(parsed.sujet, parsed.notion, args.cfg);
send(res, 200, result);
} catch (e) {
send(res, 500, { error: e instanceof Error ? e.message : String(e) });
}
return;
}
if (req.method === "GET" && (url === "/pedagogie" || url === "/pedagogie/")) {
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
res.end(buildPedagogiePage());
return;
}
if (req.method === "GET" && (url === "/admin" || url === "/admin/")) {
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
res.end(buildAdminListPage());
return;
}
if (req.method === "GET" && url.startsWith("/admin/")) {
const id = decodeURIComponent(url.slice("/admin/".length));
if (!/^[a-zA-Z0-9_-]+$/.test(id)) {
res.writeHead(400); res.end("bad id"); return;
}
const page = buildAdminSessionPage(id);
res.writeHead(page.status, { "Content-Type": "text/html; charset=utf-8" });
res.end(page.html);
return;
}
if (req.method === "POST" && url === "/alive") {
const body = await readBody(req, 256);
const { token } = JSON.parse(body || "{}") as { token?: string };
if (token) {
browsers.set(token, Date.now());
emptySince = null;
}
send(res, 200, { alive: true, browsers: browsers.size });
return;
}
if (req.method === "POST" && url === "/leave") {
// AppelĂ© via navigator.sendBeacon au pagehide â body est un Blob JSON
const body = await readBody(req, 256);
const { token } = JSON.parse(body || "{}") as { token?: string };
if (token) browsers.delete(token);
res.writeHead(204); res.end();
return;
}
if (req.method === "POST" && url === "/resume") {
const body = await readBody(req, 4 * 1024);
const { sessionId } = JSON.parse(body || "{}") as { sessionId?: string };
if (!sessionId) { send(res, 400, { error: "sessionId required" }); return; }
const loaded = loadFromDisk(sessionId);
if (!loaded) { send(res, 404, { error: "not found" }); return; }
const { filtered } = buildSystemPrompt(loaded.meta.notion, loaded.meta.sujet);
getOrResume({ id: sessionId, corpus: filtered, cfg: args.cfg });
send(res, 200, {
sessionId,
notion: loaded.meta.notion,
sujet: loaded.meta.sujet,
citationsDisponibles: filtered.length,
history: loaded.turns,
resumed: true,
});
return;
}
if (req.method === "POST" && url === "/init") {
const body = await readBody(req, 4 * 1024);
const parsed = JSON.parse(body || "{}") as {
sessionId?: string;
notion?: string;
sujet?: string;
};
const requestedNotion = (parsed.notion ?? args.defaultNotion).trim().toLowerCase();
const sujet = parsed.sujet ?? args.defaultSujet;
// Garde-fou : si la notion demandée filtre à 0 citation (slug
// inconnu, ou notion réelle mais corpus vide), on refuse plutÎt
// que de partir sur un corpus vide en silence. Bug observé
// 2026-05-18 : un LLM appelait le tool avec notion="config".
const { prompt, filtered } = buildSystemPrompt(requestedNotion, sujet);
if (filtered.length === 0) {
send(res, 400, {
error: `Notion « ${requestedNotion} » inconnue ou sans citations dans le corpus. Notions valides : liberte, conscience, devoir, etat, justice, verite, raison, bonheur, art, langage, nature, religion, science, technique, temps, travail, inconscient.`,
});
return;
}
const notion = requestedNotion;
const state = createSession({
id: parsed.sessionId,
systemPrompt: prompt,
corpus: filtered,
notion,
sujet,
cfg: args.cfg,
});
send(res, 200, {
sessionId: state.id,
notion,
sujet,
citationsDisponibles: filtered.length,
});
return;
}
if (req.method === "POST" && url === "/stream") {
const body = await readBody(req, 64 * 1024);
const parsed = JSON.parse(body || "{}") as { sessionId?: string; user_message?: string };
if (!parsed.sessionId) { send(res, 400, { error: "sessionId required" }); return; }
const loaded = loadFromDisk(parsed.sessionId);
if (!loaded) { send(res, 404, { error: "session not found" }); return; }
const { filtered } = buildSystemPrompt(loaded.meta.notion, loaded.meta.sujet);
const state = getOrResume({ id: parsed.sessionId, corpus: filtered, cfg: args.cfg });
if (!state) { send(res, 500, { error: "could not resume" }); return; }
// Si pas de message et pas encore de turns â opening synthĂ©tique
const hasUserTurns = state.messages.some((m) => m.role === "user");
const userMsg = (parsed.user_message && parsed.user_message.trim()) || (hasUserTurns ? "" : "[Démarre l'histoire.]");
if (!userMsg) { send(res, 400, { error: "user_message required" }); return; }
res.writeHead(200, {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache, no-transform",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
});
const sse = (event: string, data: unknown) => {
res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
};
// Keepalive ping pour éviter que des proxies ferment la conn
const ping = setInterval(() => res.write(": ping\n\n"), 15000);
try {
await runTurnStreaming(parsed.sessionId, userMsg, (e: StreamEvent) => {
if (e.kind === "attempt-start") sse("attempt-start", { attempt: e.attempt });
else if (e.kind === "token") sse("token", { tok: e.tok });
else if (e.kind === "retry") sse("retry", { attempt: e.attempt, reasons: e.reasons, kinds: e.kinds });
else if (e.kind === "patched") sse("patched", { content: e.content, from: e.from, to: e.to });
else if (e.kind === "done") sse("done", {
attempts: e.result.attempts,
citations: {
total: e.result.totalQuotes,
ok: e.result.matched,
hors_corpus: e.result.finalFaulty.length,
},
});
});
} catch (err) {
sse("error", { error: err instanceof Error ? err.message : String(err) });
} finally {
clearInterval(ping);
res.end();
}
return;
}
if (req.method === "POST" && url === "/reset") {
const body = await readBody(req, 4 * 1024);
const parsed = JSON.parse(body || "{}") as { sessionId?: string; notion?: string; sujet?: string };
if (parsed.sessionId) forgetSession(parsed.sessionId);
send(res, 200, { reset: true });
return;
}
res.writeHead(404); res.end();
} catch (e) {
send(res, 500, { error: e instanceof Error ? e.message : String(e) });
}
});
await new Promise<void>((resolve, reject) => {
server.once("error", reject);
server.listen(args.port, args.bindHost, () => resolve());
});
const addr = server.address() as AddressInfo;
const port = addr.port;
const url = `http://${args.bindHost === "0.0.0.0" ? "127.0.0.1" : args.bindHost}:${port}/`;
const lanUrls = args.bindHost === "0.0.0.0" ? lanAddresses(port) : [];
if (args.openBrowser) {
const openUrl = args.openPath
? url.replace(/\/$/, "") + args.openPath
: url;
const opener = process.platform === "darwin" ? "open"
: process.platform === "win32" ? "explorer" : "xdg-open";
spawn(opener, [openUrl], { detached: true, stdio: "ignore" }).unref();
}
active = {
url,
lanUrls,
close: () => new Promise<void>((resolve) => {
clearInterval(watchdog);
server.close(() => { active = null; resolve(); });
}),
};
return active;
}
function readBody(req: http.IncomingMessage, max: number): Promise<string> {
return new Promise((resolve, reject) => {
let body = "";
req.on("data", (chunk) => {
body += chunk;
if (body.length > max) { req.destroy(); reject(new Error("body too large")); }
});
req.on("end", () => resolve(body));
req.on("error", reject);
});
}
function send(res: http.ServerResponse, status: number, json: unknown) {
res.writeHead(status, { "Content-Type": "application/json" });
res.end(JSON.stringify(json));
}
function lanAddresses(port: number): string[] {
const out: string[] = [];
const ifs = networkInterfaces();
for (const name of Object.keys(ifs)) {
for (const i of ifs[name] ?? []) {
if (i.family === "IPv4" && !i.internal) out.push(`http://${i.address}:${port}/`);
}
}
return out;
}
function buildPlayPage(meta: { defaultNotion: string; defaultSujet: string }): string {
const payload = JSON.stringify(meta);
return `<!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Livre dont vous ĂȘtes le hĂ©ros â Philo Bac</title>
<style>
:root { --bg:#f7f5ef; --card:#ffffff; --ink:#1f1f1f; --muted:#6b6b6b; --line:#e5e1d6;
--accent:#1565c0; --good:#2e7d32; --warn:#c62828; --soft:#fafaf6; }
* { box-sizing: border-box; }
html, body { margin:0; padding:0; min-height:100%; background:var(--bg); color:var(--ink);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, system-ui, sans-serif; }
body { display:flex; flex-direction:column; align-items:center; padding:24px 16px 120px; }
header { width:100%; max-width:780px; margin-bottom:16px; }
header h1 { font-size:20px; margin:0 0 4px; }
header .sub { font-size:13px; color:var(--muted); }
header .meta { font-size:12px; color:var(--muted); margin-top:6px; display:flex; gap:10px; align-items:center; }
header .meta a { color:var(--accent); cursor:pointer; text-decoration:underline; }
#chat { width:100%; max-width:780px; display:flex; flex-direction:column; gap:14px; }
.turn { background:var(--card); border:1px solid var(--line); border-radius:12px; padding:18px 20px;
white-space:pre-wrap; line-height:1.55; font-size:15px; }
.turn.user { background:#eaf2fb; border-color:#cfe0f4; }
.turn .who { font-size:11px; text-transform:uppercase; letter-spacing:.08em; color:var(--muted); margin-bottom:8px; }
.turn .check { font-size:12px; color:var(--good); margin-top:10px; }
.turn .check.warn { color:var(--warn); }
.turn .check.retry { color:#ad6800; background:#fff5d6; border:1px solid #f0d99a;
padding:6px 10px; border-radius:6px; display:inline-block; margin-top:10px; }
.turn.skeleton { background:#fff8e1; border-color:#f0d99a; border-left:4px solid #ad6800; }
.turn.skeleton .who::before { content:"đ "; }
.turn.skeleton-req { background:#fffbeb; border-color:#f0d99a; border-left:4px solid #ad6800; font-style:italic; color:var(--retry); }
.turn em { font-style:italic; color:#444; }
.turn strong { font-weight:600; }
.typing { color:var(--muted); font-style:italic; display:flex; align-items:center; gap:10px; }
.spinner { width:14px; height:14px; border:2px solid var(--line); border-top-color:var(--accent);
border-radius:50%; animation:spin 0.8s linear infinite; flex-shrink:0; }
@keyframes spin { to { transform:rotate(360deg); } }
.resumed-banner { background:#e8f5e9; border:1px solid #c8e6c9; color:#1b5e20;
padding:10px 14px; border-radius:8px; font-size:13px; margin-bottom:8px; }
.composer { position:fixed; bottom:0; left:0; right:0; padding:14px 16px; background:rgba(247,245,239,0.96);
border-top:1px solid var(--line); display:flex; justify-content:center; }
.composer-inner { width:100%; max-width:780px; display:flex; gap:8px; }
.composer textarea { flex:1; min-height:44px; max-height:140px; padding:11px 12px; border:1px solid var(--line);
border-radius:10px; font:inherit; font-size:15px; resize:none; background:white; }
.composer button { padding:0 18px; font:inherit; font-weight:600; font-size:14px; border:0; border-radius:10px;
color:white; background:var(--ink); cursor:pointer; }
.composer button:disabled { opacity:.4; cursor:not-allowed; }
.quick { display:flex; gap:6px; margin-top:8px; flex-wrap:wrap; }
.quick button { padding:6px 12px; font:inherit; font-size:13px; border:1px solid var(--line);
border-radius:6px; background:white; cursor:pointer; }
.quick button:hover { background:var(--soft); }
</style>
</head>
<body>
<header>
<h1>Livre dont vous ĂȘtes le hĂ©ros â Philo Bac</h1>
<div class="sub" id="subject"></div>
<div class="meta" id="meta"></div>
</header>
<main id="chat"></main>
<div class="composer">
<div class="composer-inner">
<textarea id="ta" placeholder="A, B, C â ou Ă©cris ta propre rĂ©ponseâŠ" rows="1"></textarea>
<button id="send" type="button">Envoyer</button>
</div>
</div>
<script>
"use strict";
const DEFAULTS = ${payload};
const SUBJ = document.getElementById("subject");
const META = document.getElementById("meta");
const chat = document.getElementById("chat");
const ta = document.getElementById("ta");
const sendBtn = document.getElementById("send");
let busy = false;
// Si on arrive depuis le catalogue avec ?notion=X&sujet=Y, on force une
// nouvelle session (le catalogue a déjà cleared le localStorage).
const qs = new URLSearchParams(window.location.search);
const overrideNotion = qs.get("notion");
const overrideSujet = qs.get("sujet");
const hasOverride = !!(overrideNotion && overrideSujet);
let sessionId = hasOverride ? null : (localStorage.getItem("livreHerosSessionId") || null);
// Token de prĂ©sence (ephemĂšre, par onglet â pas localStorage). Permet au serveur
// de compter combien d'onglets sont vivants et de s'auto-arrĂȘter quand tous fermĂ©s.
const browserToken = (crypto && crypto.randomUUID ? crypto.randomUUID() : String(Math.random()).slice(2));
let sessionNotion = overrideNotion || DEFAULTS.defaultNotion;
let sessionSujet = overrideSujet || DEFAULTS.defaultSujet;
let sessionCitations = 0;
let narratorTurnCount = 0;
let skeletonShown = false;
const SKELETON_PROMPT = "TrĂšs bien â montre-moi le squelette de dissertation que j'ai construit dans cette histoire : problĂ©matique, plan en 2 ou 3 parties, et une citation du corpus par partie.";
function el(tag, attrs, ...kids) {
const n = document.createElement(tag);
if (attrs) for (const k of Object.keys(attrs)) {
if (k === "class") n.className = attrs[k];
else if (k.startsWith("on")) n.addEventListener(k.slice(2), attrs[k]);
else n.setAttribute(k, String(attrs[k]));
}
for (const c of kids) {
if (c == null || c === false) continue;
if (typeof c === "string") n.appendChild(document.createTextNode(c));
else n.appendChild(c);
}
return n;
}
function clearNode(n) { while (n.firstChild) n.removeChild(n.firstChild); }
function refreshHeader() {
SUBJ.textContent = sessionSujet;
clearNode(META);
META.appendChild(document.createTextNode(
"Notion : " + sessionNotion + " · " + sessionCitations + " citations vérifiées · "
));
const resetLink = el("a", { onclick: doReset }, "Recommencer");
META.appendChild(resetLink);
META.appendChild(document.createTextNode(" · "));
const catalogLink = el("a", { href: "/" }, "â Catalogue");
META.appendChild(catalogLink);
META.appendChild(document.createTextNode(" · "));
const adminLink = el("a", { href: "/admin", target: "_blank" }, "Toutes les sessions");
META.appendChild(adminLink);
if (narratorTurnCount >= 3 && !skeletonShown) {
META.appendChild(document.createTextNode(" · "));
const skelLink = el("a", { onclick: doSkeleton }, "đ Voir le squelette de dissertation");
META.appendChild(skelLink);
}
if (sessionId) {
META.appendChild(document.createTextNode(" · "));
const idSpan = el("span", { class: "muted" }, "session " + sessionId.slice(0, 6) + "âŠ");
META.appendChild(idSpan);
}
}
function renderNarratorContent(container, text) {
const lines = text.split("\\n");
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
let cursor = 0;
for (const m of line.matchAll(/\\*\\*([^*]+)\\*\\*|\\*([^*]+)\\*/g)) {
const idx = m.index;
if (idx > cursor) container.appendChild(document.createTextNode(line.slice(cursor, idx)));
if (m[1]) container.appendChild(el("strong", null, m[1]));
else if (m[2]) container.appendChild(el("em", null, m[2]));
cursor = idx + m[0].length;
}
if (cursor < line.length) container.appendChild(document.createTextNode(line.slice(cursor)));
if (i < lines.length - 1) container.appendChild(el("br"));
}
}
function addUserTurn(textValue) {
const node = el("div", { class: "turn user" }, el("div", { class: "who" }, "Toi"));
node.appendChild(document.createTextNode(textValue));
chat.appendChild(node);
window.scrollTo({ top: document.body.scrollHeight, behavior: "smooth" });
}
function addNarratorTurn(reply, citations, attempts) {
const node = el("div", { class: "turn" }, el("div", { class: "who" }, "Narrateur"));
const body = el("div");
renderNarratorContent(body, reply);
node.appendChild(body);
const retries = attempts ? attempts.length - 1 : 0;
const realPhilo = citations ? (citations.ok + citations.hors_corpus) : 0;
const hasFaulty = citations && citations.hors_corpus > 0;
if (hasFaulty) {
const check = el("div", { class: "check warn" });
check.textContent = "â " + citations.hors_corpus + " citation(s) hors corpus aprĂšs " + retries + " tentative(s) â affichĂ©es telles quelles";
node.appendChild(check);
} else if (realPhilo > 0) {
const check = el("div", { class: "check" });
check.textContent = "â " + citations.ok + " citation(s) du corpus vĂ©rifiĂ©e(s)";
node.appendChild(check);
}
if (retries > 0) {
const r = el("div", { class: "check retry" });
r.textContent = "⻠" + retries + " révision" + (retries > 1 ? "s" : "") +
" automatique" + (retries > 1 ? "s" : "") +
" â le narrateur a tentĂ© une citation hors corpus, le vĂ©rificateur l'a corrigĂ© en silence";
node.appendChild(r);
}
chat.appendChild(node);
addQuickButtons(reply);
window.scrollTo({ top: document.body.scrollHeight, behavior: "smooth" });
}
function addQuickButtons(reply) {
const opts = [];
for (const line of reply.split("\\n")) {
const m = line.match(/^\\s*([A-D])[\\.\\)]\\s/);
if (m) opts.push(m[1]);
}
if (opts.length < 2) return;
const wrap = el("div", { class: "quick" });
for (const o of opts) {
wrap.appendChild(el("button", { type: "button", onclick: () => { ta.value = o; doSend(); } }, o));
}
chat.appendChild(wrap);
}
function setBusy(b) {
busy = b;
sendBtn.disabled = b;
ta.disabled = b;
sendBtn.textContent = b ? "âŠ" : "Envoyer";
}
async function streamTurn(userMessage, opts) {
opts = opts || {};
// Construit le bloc narrateur "en cours" et streame les tokens dedans.
let turnBox = el("div", { class: "turn" + (opts.skeleton ? " skeleton" : "") },
el("div", { class: "who" }, opts.skeleton ? "Squelette de dissertation" : "Narrateur"));
let body = el("div");
turnBox.appendChild(body);
const statusLine = el("div", { class: "check retry" });
statusLine.style.display = "none";
turnBox.appendChild(statusLine);
chat.appendChild(turnBox);
// Spinner + label de temps écoulé jusqu'au 1er token streamé. Le label
// est mis Ă jour toutes les 500ms par tickTimer ; le spinner CSS tourne
// indépendamment via animation keyframes (pas de JS dans la boucle).
let waiting = el("div", { class: "typing" });
const waitingSpinner = el("div", { class: "spinner" });
const waitingLabel = el("span", null, "Le narrateur réfléchit⊠0s");
waiting.appendChild(waitingSpinner);
waiting.appendChild(waitingLabel);
const startedAt = Date.now();
body.appendChild(waiting);
const tickTimer = setInterval(() => {
if (!waiting.isConnected) return;
const s = Math.round((Date.now() - startedAt) / 1000);
waitingLabel.textContent = "Le narrateur réfléchit⊠" + s + "s";
}, 500);
window.scrollTo({ top: document.body.scrollHeight, behavior: "smooth" });
let buffer = "";
let attempt = 1;
let finalCitations = null;
let finalAttempts = null;
function repaintBody(text) {
clearNode(body);
renderNarratorContent(body, text);
}
const res = await fetch("/stream", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ sessionId, user_message: userMessage }),
});
if (!res.ok || !res.body) {
clearInterval(tickTimer);
repaintBody("Erreur : " + res.status);
return null;
}
const reader = res.body.getReader();
const decoder = new TextDecoder();
let sseBuf = "";
let evt = "";
while (true) {
const { value, done } = await reader.read();
if (done) break;
sseBuf += decoder.decode(value, { stream: true });
let nl;
while ((nl = sseBuf.indexOf("\\n")) !== -1) {
const line = sseBuf.slice(0, nl);
sseBuf = sseBuf.slice(nl + 1);
if (line.startsWith("event:")) { evt = line.slice(6).trim(); continue; }
if (!line.startsWith("data:")) continue;
const data = line.slice(5).trim();
if (!data) continue;
let payload;
try { payload = JSON.parse(data); } catch { continue; }
if (evt === "attempt-start") {
attempt = payload.attempt;
// NE PAS retirer le spinner ici : attempt-start est émis AVANT
// que streamComplete soit appelé, donc bien avant le 1er token.
// Le spinner doit rester visible tant que le LLM réfléchit
// (parfois 10-20s sur Qwen3-14B). C'est le handler 'token'
// ci-dessous qui le retire, à l'arrivée effective du 1er token.
} else if (evt === "token") {
if (waiting && waiting.isConnected) { clearInterval(tickTimer); body.removeChild(waiting); waiting = null; }
buffer += payload.tok;
repaintBody(buffer);
window.scrollTo({ top: document.body.scrollHeight, behavior: "smooth" });
} else if (evt === "retry") {
// Message honnĂȘte : on prĂ©cise la nature de la faute (Ă©tat ou
// citation) â avant on disait toujours « citation hors corpus »
// alors que la majorité des retries observés sont des fautes
// d'Ă©tat (le modĂšle tĂȘtu sur le numĂ©ro d'opĂ©ration).
// Message honnĂȘte sur la nature de la rĂ©vision. On compose le
// label selon les types de violation détectés (peut cumuler
// citation hallucinée + état + squelette).
const ks = payload.kinds || ["citation"];
const labels = [];
if (ks.includes("citation")) labels.push("citation hors corpus");
if (ks.includes("state")) labels.push("état d'avancement");
if (ks.includes("skeleton")) labels.push("squelette mal aligné");
const kindLabel = labels.length > 0 ? labels.join(" + ") : "vérification";
statusLine.style.display = "inline-block";
statusLine.textContent = "â» VĂ©rificateur : " + kindLabel + " â rĂ©vision " + (payload.attempt - 1) +
(payload.reasons && payload.reasons.length ? " (" + payload.reasons[0] + ")" : "");
buffer = "";
repaintBody("");
const reWaiting = el("div", { class: "typing" },
el("div", { class: "spinner" }),
el("span", null, "Reprise du narrateur (rĂ©vision " + (payload.attempt - 1) + ")âŠ"));
body.appendChild(reWaiting);
} else if (evt === "patched") {
// Le serveur a corrigĂ© l'Ă©tiquette « OpĂ©ration N/5 â carte » que
// le modĂšle n'arrivait pas Ă Ă©crire juste, mĂȘme aprĂšs retries.
// On repeint avec la version corrigée et on log un badge.
buffer = payload.content;
repaintBody(buffer);
const patchBadge = el("div", { class: "check retry" });
patchBadge.textContent = "â Ătiquette corrigĂ©e par le serveur : "
+ (payload.from || "â") + " â " + (payload.to || "â");
turnBox.appendChild(patchBadge);
} else if (evt === "done") {
finalCitations = payload.citations;
finalAttempts = payload.attempts;
} else if (evt === "error") {
repaintBody("Erreur : " + (payload.error || "inconnue"));
}
}
}
clearInterval(tickTimer);
if (waiting && waiting.isConnected) body.removeChild(waiting);
// Enrichit le bloc final avec les badges vérif
statusLine.remove();
const retries = finalAttempts ? finalAttempts.length - 1 : 0;
const realPhilo = finalCitations ? (finalCitations.ok + finalCitations.hors_corpus) : 0;
if (finalCitations && finalCitations.hors_corpus > 0) {
const check = el("div", { class: "check warn" });
check.textContent = "â " + finalCitations.hors_corpus + " citation(s) hors corpus aprĂšs " + retries + " tentative(s)";
turnBox.appendChild(check);
} else if (realPhilo > 0) {
const check = el("div", { class: "check" });
check.textContent = "â " + finalCitations.ok + " citation(s) du corpus vĂ©rifiĂ©e(s)";
turnBox.appendChild(check);
}
if (retries > 0) {
const r = el("div", { class: "check retry" });
r.textContent = "⻠" + retries + " révision" + (retries > 1 ? "s" : "") +
" automatique" + (retries > 1 ? "s" : "") + " â le vĂ©rificateur a rejetĂ© une citation hors corpus";
turnBox.appendChild(r);
}
if (!opts.skeleton) addQuickButtons(buffer);
narratorTurnCount++;
if (opts.skeleton) skeletonShown = true;
refreshHeader();
window.scrollTo({ top: document.body.scrollHeight, behavior: "smooth" });
// Auto-déclenchement du squelette si le narrateur a annoncé le climax 5/5
if (!opts.skeleton && !skeletonShown && /\\b5\\s*\\/\\s*5\\b/.test(buffer) && /squelette/i.test(buffer)) {
const countdown = el("div", { class: "check retry" });
countdown.textContent = "âȘ Le squelette s'ouvrira dans 3 sâŠ";
chat.appendChild(countdown);
let remaining = 3;
const tick = setInterval(() => {
remaining--;
if (remaining <= 0 || skeletonShown || busy) {
clearInterval(tick);
if (countdown.isConnected) countdown.remove();
if (!skeletonShown && !busy) doSkeleton();
} else {
countdown.textContent = "âȘ Le squelette s'ouvrira dans " + remaining + " sâŠ";
}
}, 1000);
}
}
async function doSkeleton() {
if (busy || skeletonShown) return;
if (!sessionId) return;
// Bulle "Toi" courte + bloc squelette stylisé
const userBubble = el("div", { class: "turn user skeleton-req" },
el("div", { class: "who" }, "Toi"));
userBubble.appendChild(document.createTextNode("đ Demande du squelette de dissertation"));
chat.appendChild(userBubble);
setBusy(true);
try {
await streamTurn(SKELETON_PROMPT, { skeleton: true });
} catch (e) {
addNarratorTurn("Erreur réseau : " + e, null, null);
} finally {
setBusy(false);
ta.focus();
}
}
async function doSend() {
if (busy) return;
const textValue = ta.value.trim();
if (!textValue) return;
if (!sessionId) { alert("Pas de session active."); return; }
if (textValue === "/reset") { ta.value = ""; await doReset(); return; }
ta.value = "";
addUserTurn(textValue);
setBusy(true);
try {
await streamTurn(textValue);
} catch (e) {
addNarratorTurn("Erreur réseau : " + e, null, null);
} finally {
setBusy(false);
ta.focus();
}
}
async function doReset() {
if (busy) return;
setBusy(true);
try {
await fetch("/reset", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ sessionId }),
});
} catch {}
localStorage.removeItem("livreHerosSessionId");
sessionId = null;
clearNode(chat);
await bootstrap();
setBusy(false);
}
function renderHistory(turns) {
let pendingSkeleton = false;
for (const t of turns) {
if (t.role === "user") {
if (t.content === "[Démarre l'histoire.]") continue;
if (t.content === SKELETON_PROMPT) {
pendingSkeleton = true;
skeletonShown = true;
const bubble = el("div", { class: "turn user skeleton-req" }, el("div", { class: "who" }, "Toi"));
bubble.appendChild(document.createTextNode("đ Demande du squelette de dissertation"));
chat.appendChild(bubble);
} else {
addUserTurn(t.content);
}
} else {
if (pendingSkeleton) {
renderRestoredNarrator(t, true);
pendingSkeleton = false;
} else {
renderRestoredNarrator(t, false);
}
narratorTurnCount++;
}
}
}
function renderRestoredNarrator(t, isSkeleton) {
const node = el("div", { class: "turn" + (isSkeleton ? " skeleton" : "") },
el("div", { class: "who" }, isSkeleton ? "Squelette de dissertation" : "Narrateur"));
const body = el("div");
renderNarratorContent(body, t.content);
node.appendChild(body);
const retries = t.attempts ? t.attempts.length - 1 : 0;
const realPhilo = (t.matched || 0) + (t.faulty || 0);
if ((t.faulty || 0) > 0) {
const check = el("div", { class: "check warn" });
check.textContent = "â " + t.faulty + " citation(s) hors corpus aprĂšs " + retries + " tentative(s)";
node.appendChild(check);
} else if (realPhilo > 0) {
const check = el("div", { class: "check" });
check.textContent = "â " + t.matched + " citation(s) du corpus vĂ©rifiĂ©e(s)";
node.appendChild(check);
}
if (retries > 0) {
const r = el("div", { class: "check retry" });
r.textContent = "⻠" + retries + " révision(s) automatique(s)";
node.appendChild(r);
}
chat.appendChild(node);
if (!isSkeleton) addQuickButtons(t.content);
}
async function tryResume() {
if (!sessionId) return false;
try {
const res = await fetch("/resume", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ sessionId }),
});
if (!res.ok) return false;
const data = await res.json();
sessionNotion = data.notion;
sessionSujet = data.sujet;
sessionCitations = data.citationsDisponibles;
const banner = el("div", { class: "resumed-banner" },
"Session reprise â " + (data.history ? data.history.length : 0) + " tour(s) restaurĂ©(s).");
chat.appendChild(banner);
if (data.history) renderHistory(data.history);
refreshHeader(); // aprĂšs renderHistory pour que le compteur soit Ă jour
return true;
} catch { return false; }
}
async function bootstrap() {
setBusy(true);
refreshHeader();
if (await tryResume()) { setBusy(false); ta.focus(); return; }
try {
const initBody = hasOverride
? { notion: overrideNotion, sujet: overrideSujet }
: {};
const res = await fetch("/init", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(initBody),
});
const data = await res.json();
if (!res.ok) {
addNarratorTurn("Erreur init : " + (data.error || res.statusText), null, null);
setBusy(false); return;
}
sessionId = data.sessionId;
sessionNotion = data.notion;
sessionSujet = data.sujet;
sessionCitations = data.citationsDisponibles;
localStorage.setItem("livreHerosSessionId", sessionId);
refreshHeader();
await streamTurn(""); // opening synthétique
} catch (e) {
addNarratorTurn("Erreur réseau : " + e, null, null);
} finally {
setBusy(false);
ta.focus();
}
}
ta.addEventListener("keydown", (e) => {
if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); doSend(); }
});
sendBtn.addEventListener("click", doSend);
// Heartbeat : ping serveur toutes les 10s pour dire "cet onglet est vivant".
// Le serveur tient un refcount et s'auto-arrĂȘte quand tous les onglets sont fermĂ©s.
function ping() {
fetch("/alive", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token: browserToken }),
}).catch(() => {});
}
ping();
const heartbeatId = setInterval(ping, 10000);
// pagehide : signal immĂ©diat de fermeture (sendBeacon survit mĂȘme pendant l'unload).
window.addEventListener("pagehide", () => {
clearInterval(heartbeatId);
try {
const blob = new Blob([JSON.stringify({ token: browserToken })], { type: "application/json" });
navigator.sendBeacon("/leave", blob);
} catch {}
});
bootstrap();
</script>
</body>
</html>`;
}