Project Files
src / admin.ts
// Vue admin HTML â liste les sessions persistĂ©es + transcript dĂ©taillĂ© d'une session.
// Endpoints servis par web.ts : GET /admin et GET /admin/<sessionId>
// Lecture seule, pas d'auth (binding LAN â pour des donnĂ©es sensibles, on couperait via config).
import { listSessions, loadSession } from "./storage";
function escapeHtml(s: string): string {
return s.replace(/[&<>"']/g, (c) =>
c === "&" ? "&" : c === "<" ? "<" : c === ">" ? ">" : c === '"' ? """ : "'");
}
function fmtDate(ms: number): string {
const d = new Date(ms);
const pad = (n: number) => String(n).padStart(2, "0");
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
function fmtAgo(ms: number): string {
const s = Math.round((Date.now() - ms) / 1000);
if (s < 60) return `il y a ${s}s`;
if (s < 3600) return `il y a ${Math.round(s / 60)} min`;
if (s < 86400) return `il y a ${Math.round(s / 3600)} h`;
return `il y a ${Math.round(s / 86400)} j`;
}
const STYLES = `
:root { --bg:#f7f5ef; --card:#ffffff; --ink:#1f1f1f; --muted:#6b6b6b; --line:#e5e1d6;
--accent:#1565c0; --good:#2e7d32; --warn:#c62828; --retry:#ad6800; --retry-bg:#fff5d6; --soft:#fafaf6; }
* { box-sizing:border-box; }
html,body { margin:0; padding:0; background:var(--bg); color:var(--ink);
font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,system-ui,sans-serif; }
body { padding:24px 16px; }
.wrap { max-width:980px; margin:0 auto; }
h1 { font-size:20px; margin:0 0 6px; }
.crumb { font-size:13px; color:var(--muted); margin-bottom:18px; }
.crumb a { color:var(--accent); text-decoration:none; }
.crumb a:hover { text-decoration:underline; }
table { width:100%; border-collapse:collapse; background:var(--card); border:1px solid var(--line);
border-radius:10px; overflow:hidden; }
th,td { padding:10px 14px; text-align:left; font-size:14px; border-bottom:1px solid var(--line); }
th { background:var(--soft); font-weight:600; color:var(--muted); font-size:12px; text-transform:uppercase; letter-spacing:.05em; }
tr:last-child td { border-bottom:0; }
tr.row:hover { background:#fcfaf3; }
td.right { text-align:right; }
td .small { color:var(--muted); font-size:12px; }
a.link { color:var(--accent); text-decoration:none; }
a.link:hover { text-decoration:underline; }
.empty { padding:32px; text-align:center; color:var(--muted); background:var(--card);
border:1px solid var(--line); border-radius:10px; }
details.sys { background:var(--card); border:1px solid var(--line); border-radius:10px;
padding:12px 16px; margin-bottom:18px; }
details.sys summary { cursor:pointer; font-size:13px; color:var(--muted); }
details.sys pre { white-space:pre-wrap; font-size:12px; color:#333; margin:12px 0 0;
max-height:340px; overflow:auto; background:var(--soft); padding:10px; border-radius:6px; }
.meta-grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(160px,1fr));
gap:10px; background:var(--card); border:1px solid var(--line); border-radius:10px;
padding:14px; margin-bottom:18px; }
.meta-grid .cell { font-size:13px; }
.meta-grid .cell .label { color:var(--muted); font-size:11px; text-transform:uppercase; letter-spacing:.05em; }
.meta-grid .cell .val { color:var(--ink); margin-top:2px; }
.turn { background:var(--card); border:1px solid var(--line); border-radius:10px;
padding:14px 18px; margin-bottom:10px; }
.turn.user { background:#eaf2fb; border-color:#cfe0f4; }
.turn .head { display:flex; justify-content:space-between; align-items:baseline; margin-bottom:8px; }
.turn .who { font-size:11px; text-transform:uppercase; letter-spacing:.08em; color:var(--muted); font-weight:600; }
.turn .time { font-size:11px; color:var(--muted); }
.turn .body { white-space:pre-wrap; line-height:1.55; font-size:14px; }
.turn .body em { font-style:italic; color:#444; }
.turn .body strong { font-weight:600; }
.turn .badges { display:flex; gap:8px; flex-wrap:wrap; margin-top:10px; }
.badge { font-size:11px; padding:3px 8px; border-radius:5px; display:inline-block; }
.badge.ok { color:var(--good); background:#e8f5e9; border:1px solid #c8e6c9; }
.badge.warn { color:var(--warn); background:#fde8e8; border:1px solid #f5c6cb; }
.badge.retry { color:var(--retry); background:var(--retry-bg); border:1px solid #f0d99a; }
.badge.dim { color:var(--muted); background:#f0eee5; border:1px solid var(--line); }
.audits { margin-top:12px; border:1px dashed var(--line); border-radius:6px; padding:8px 12px;
background:var(--soft); font-size:12px; }
.audits .head { color:var(--muted); font-size:11px; text-transform:uppercase; letter-spacing:.05em; margin-bottom:6px; }
.audits .row { padding:4px 0; border-bottom:1px solid var(--line); }
.audits .row:last-child { border-bottom:0; }
.audits .quote { font-style:italic; color:#333; }
.audits .tag { display:inline-block; padding:1px 6px; border-radius:3px; font-size:10px;
text-transform:uppercase; letter-spacing:.04em; margin-right:6px; }
.audits .tag.matched { background:#e8f5e9; color:var(--good); }
.audits .tag.faulty { background:#fde8e8; color:var(--warn); }
.audits .tag.skipped { background:#f0eee5; color:var(--muted); }
.audits .meta-line { color:var(--muted); font-size:11px; margin-top:2px; }
.note { font-size:11px; color:var(--muted); margin-top:18px; text-align:center; }
`;
function renderMarkdown(text: string): string {
// Ăchappe d'abord, puis applique **bold** et *italic*.
const esc = escapeHtml(text);
return esc
.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>")
.replace(/(^|[^*])\*([^*\n]+)\*/g, "$1<em>$2</em>");
}
export function buildAdminListPage(): string {
const sessions = listSessions(7 * 24 * 3600 * 1000); // 7 jours
const rows = sessions.map((s) => `
<tr class="row">
<td><a class="link" href="/admin/${encodeURIComponent(s.id)}">${escapeHtml(s.id)}</a></td>
<td>${escapeHtml(s.notion)}</td>
<td>${escapeHtml(s.sujet)}</td>
<td>${s.turnCount}</td>
<td>${fmtDate(s.createdAt)}<div class="small">${fmtAgo(s.createdAt)}</div></td>
<td>${fmtDate(s.lastTurnAt)}<div class="small">${fmtAgo(s.lastTurnAt)}</div></td>
</tr>
`).join("");
const body = sessions.length === 0
? `<div class="empty">Aucune session enregistrée. Joue une partie d'abord.</div>`
: `<table>
<thead><tr>
<th>session id</th><th>notion</th><th>sujet</th><th>tours</th><th>créée</th><th>dernier tour</th>
</tr></thead>
<tbody>${rows}</tbody>
</table>`;
return `<!doctype html>
<html lang="fr"><head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Sessions livre-hĂ©ros â admin</title>
<style>${STYLES}</style>
</head><body><div class="wrap">
<h1>Sessions livre-héros</h1>
<div class="crumb"><a href="/">â retour Ă la dĂ©mo</a> · ${sessions.length} session(s) sur les 7 derniers jours</div>
${body}
<div class="note">Lecture seule. Fichiers source dans ~/.livre-heros-bac/sessions/.</div>
</div></body></html>`;
}
export function buildAdminSessionPage(id: string): { status: number; html: string } {
const loaded = loadSession(id);
if (!loaded) {
return {
status: 404,
html: `<!doctype html><html><head><meta charset="utf-8"><title>404</title><style>${STYLES}</style></head>
<body><div class="wrap"><h1>Session introuvable</h1><div class="crumb">
<a href="/admin">â liste des sessions</a></div>
<div class="empty">Aucune session avec l'id <code>${escapeHtml(id)}</code>.</div></div></body></html>`,
};
}
const turns = loaded.turns;
let prevT: number | null = null;
const turnHtml = turns.map((t) => {
const isUser = t.role === "user";
if (isUser && t.content === "[Démarre l'histoire.]") return ""; // synthétique
const dt = prevT != null ? Math.round((t.t - prevT) / 1000) : 0;
prevT = t.t;
const gap = dt > 0 ? `<span class="time">+${dt}s</span>` : "";
const head = `<div class="head"><span class="who">${isUser ? "Toi" : "Narrateur"}</span>${gap}</div>`;
const body = `<div class="body">${renderMarkdown(t.content)}</div>`;
let badges = "";
if (!isUser) {
const retries = t.attempts ? t.attempts.length - 1 : 0;
const real = (t.matched ?? 0) + (t.faulty ?? 0);
const parts: string[] = [];
if (real > 0 && (t.faulty ?? 0) === 0) {
parts.push(`<span class="badge ok">â ${t.matched} citation(s) corpus</span>`);
} else if ((t.faulty ?? 0) > 0) {
parts.push(`<span class="badge warn">â ${t.faulty} hors corpus</span>`);
}
if (retries > 0) {
parts.push(`<span class="badge retry">⻠${retries} révision(s)</span>`);
}
if (t.labelPatch) {
const from = escapeHtml(t.labelPatch.from ?? "â");
const to = escapeHtml(t.labelPatch.to ?? "â");
parts.push(`<span class="badge retry">â label patchĂ© : ${from} â ${to}</span>`);
}
if (t.stateViolation) {
parts.push(`<span class="badge warn">â Ă©tat non conforme : ${escapeHtml(t.stateViolation.summary)}</span>`);
}
if (t.skeletonViolations && t.skeletonViolations.length > 0) {
for (const sv of t.skeletonViolations) {
parts.push(`<span class="badge warn">â squelette : ${escapeHtml(sv.summary)}</span>`);
}
}
if (parts.length === 0) {
parts.push(`<span class="badge dim">(pas de citation détectée)</span>`);
}
badges = `<div class="badges">${parts.join("")}</div>`;
}
let auditsHtml = "";
if (!isUser && t.audits && t.audits.length > 0) {
const rows = t.audits.map((a) => {
const tag = `<span class="tag ${a.status}">${a.status}</span>`;
const quote = `<span class="quote">« ${escapeHtml(a.raw.slice(0, 160))}${a.raw.length > 160 ? "âŠ" : ""} »</span>`;
let metaLine = "";
if (a.status === "matched" && a.matchedTo) {
metaLine = `<div class="meta-line">â ${escapeHtml(a.matchedTo.auteur)}, ${escapeHtml(a.matchedTo.oeuvre)} · dist=${a.bestDist?.toFixed(3)}</div>`;
} else if (a.status === "faulty") {
if (a.faultyReason === "attribution_mismatch") {
metaLine = `<div class="meta-line">attribuée à ${escapeHtml(a.attributedTo ?? "?")}, en réalité ${escapeHtml(a.actualAuteur ?? "?")} (dist=${a.bestDist?.toFixed(3)})</div>`;
} else {
metaLine = `<div class="meta-line">hors corpus (dist=${a.bestDist?.toFixed(3)}, auteur détecté : ${escapeHtml(a.attributedTo ?? "?")} ${a.attributionPosition ? `[${a.attributionPosition}]` : ""})</div>`;
}
} else {
metaLine = `<div class="meta-line">aucun auteur du programme dĂ©tectĂ© autour â ignorĂ©e (dist meilleur match=${a.bestDist?.toFixed(3) ?? "â"})</div>`;
}
return `<div class="row">${tag}${quote}${metaLine}</div>`;
}).join("");
auditsHtml = `<div class="audits"><div class="head">Audit citation par citation</div>${rows}</div>`;
}
return `<div class="turn ${isUser ? "user" : ""}">${head}${body}${badges}${auditsHtml}</div>`;
}).join("");
const meta = loaded.meta;
return {
status: 200,
html: `<!doctype html>
<html lang="fr"><head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>${escapeHtml(meta.sujet)} â session ${escapeHtml(meta.id.slice(0, 8))}</title>
<style>${STYLES}</style>
</head><body><div class="wrap">
<h1>${escapeHtml(meta.sujet)}</h1>
<div class="crumb"><a href="/admin">â toutes les sessions</a> · session <code>${escapeHtml(meta.id)}</code></div>
<div class="meta-grid">
<div class="cell"><div class="label">notion</div><div class="val">${escapeHtml(meta.notion)}</div></div>
<div class="cell"><div class="label">créée le</div><div class="val">${fmtDate(meta.createdAt)}</div></div>
<div class="cell"><div class="label">tours</div><div class="val">${turns.length}</div></div>
<div class="cell"><div class="label">dernier tour</div><div class="val">${turns.length ? fmtAgo(turns[turns.length - 1].t) : "â"}</div></div>
</div>
<details class="sys">
<summary>System prompt (snapshot au moment de la création)</summary>
<pre>${escapeHtml(meta.systemPrompt)}</pre>
</details>
${turnHtml || `<div class="empty">Aucun tour joué.</div>`}
<div class="note">Source : ~/.livre-heros-bac/sessions/${escapeHtml(meta.id)}.jsonl</div>
</div></body></html>`,
};
}