cert-coach-web 0.1.0 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +4 -2
- package/README.md +15 -4
- package/engine/core/cert-core.mjs +6 -1
- package/engine/extractor.js +1 -1
- package/engine/scripts/export_private.ps1 +8 -6
- package/package.json +4 -3
- package/web/public/app.js +77 -1013
- package/web/public/index.html +1 -1
- package/web/public/spa-api.js +33 -0
- package/web/public/spa-chat.js +181 -0
- package/web/public/spa-dom.js +89 -0
- package/web/public/spa-logic.js +20 -0
- package/web/public/spa-mermaid.js +54 -0
- package/web/public/spa-question.js +206 -0
- package/web/public/spa-render.js +107 -0
- package/web/public/spa-store.js +100 -0
- package/web/public/spa-ui.js +62 -0
- package/web/public/spa-views.js +279 -0
- package/web/public/styles.css +1 -0
- package/web/server/chat.mjs +74 -4
- package/web/server/http.mjs +7 -2
package/web/public/app.js
CHANGED
|
@@ -1,443 +1,21 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
idle: $("idle-card"),
|
|
14
|
-
idleNote: $("idle-note"),
|
|
15
|
-
start: $("start"),
|
|
16
|
-
startMock: $("start-mock"),
|
|
17
|
-
startProbe: $("start-probe"),
|
|
18
|
-
mockSize: $("mock-size"),
|
|
19
|
-
mockMinutes: $("mock-minutes"),
|
|
20
|
-
question: $("question-card"),
|
|
21
|
-
qmeta: $("qmeta"),
|
|
22
|
-
timer: $("timer"),
|
|
23
|
-
stem: $("stem"),
|
|
24
|
-
questionNote: $("question-note"),
|
|
25
|
-
images: $("images"),
|
|
26
|
-
options: $("options"),
|
|
27
|
-
confidenceButtons: $("confidence-buttons"),
|
|
28
|
-
submit: $("submit"),
|
|
29
|
-
feedback: $("feedback-card"),
|
|
30
|
-
verdict: $("verdict"),
|
|
31
|
-
feedbackMeta: $("feedback-meta"),
|
|
32
|
-
feedbackTitle: $("feedback-title"),
|
|
33
|
-
answerLine: $("answer-line"),
|
|
34
|
-
explanation: $("explanation"),
|
|
35
|
-
source: $("source"),
|
|
36
|
-
next: $("next"),
|
|
37
|
-
teach: $("teach-card"),
|
|
38
|
-
teachMeta: $("teach-meta"),
|
|
39
|
-
teachTitle: $("teach-title"),
|
|
40
|
-
teachSummary: $("teach-summary"),
|
|
41
|
-
teachSection: $("teach-section"),
|
|
42
|
-
teachDoc: $("teach-doc"),
|
|
43
|
-
teachNext: $("teach-next"),
|
|
44
|
-
mock: $("mock-card"),
|
|
45
|
-
mockMeta: $("mock-meta"),
|
|
46
|
-
mockTitle: $("mock-title"),
|
|
47
|
-
mockNote: $("mock-note"),
|
|
48
|
-
mockResults: $("mock-results"),
|
|
49
|
-
mockNew: $("mock-new"),
|
|
50
|
-
mockPractice: $("mock-practice"),
|
|
51
|
-
statusLog: $("status-log"),
|
|
52
|
-
chatLog: $("chat-log"),
|
|
53
|
-
chatForm: $("chat-form"),
|
|
54
|
-
chatInput: $("chat-input"),
|
|
55
|
-
chatSend: $("chat-send"),
|
|
56
|
-
chatStop: $("chat-stop"),
|
|
57
|
-
chatStatus: $("chat-status"),
|
|
58
|
-
nav: document.querySelector(".nav"),
|
|
59
|
-
sideNav: document.querySelector(".side-nav"),
|
|
60
|
-
layoutButtons: [...document.querySelectorAll("[data-app-layout]")],
|
|
61
|
-
cmdReadiness: $("cmd-readiness"),
|
|
62
|
-
cmdCoverage: $("cmd-coverage"),
|
|
63
|
-
cmdDue: $("cmd-due"),
|
|
64
|
-
cmdWeak: $("cmd-weak"),
|
|
65
|
-
cmdWeakNote: $("cmd-weak-note"),
|
|
66
|
-
cmdReadinessBar: $("cmd-readiness-bar"),
|
|
67
|
-
cmdCoverageBar: $("cmd-coverage-bar"),
|
|
68
|
-
cmdDomains: $("cmd-domains"),
|
|
69
|
-
studyView: $("study-view"),
|
|
70
|
-
pathView: $("path-view"),
|
|
71
|
-
pathMeta: $("path-meta"),
|
|
72
|
-
pathRoute: $("path-route"),
|
|
73
|
-
pathDetail: $("path-detail"),
|
|
74
|
-
dashboardView: $("dashboard-view"),
|
|
75
|
-
theoryView: $("theory-view"),
|
|
76
|
-
logsView: $("logs-view"),
|
|
77
|
-
dashboardBody: $("dashboard-body"),
|
|
78
|
-
dashboardMeta: $("dashboard-meta"),
|
|
79
|
-
theoryNodes: $("theory-nodes"),
|
|
80
|
-
theoryBody: $("theory-body"),
|
|
81
|
-
logsBody: $("logs-body"),
|
|
82
|
-
};
|
|
83
|
-
|
|
84
|
-
let state = null;
|
|
85
|
-
let selected = new Set();
|
|
86
|
-
let confidence = 2;
|
|
87
|
-
let startedAt = 0;
|
|
88
|
-
let questionKey = "";
|
|
89
|
-
let timerHandle = null;
|
|
90
|
-
let eventSource = null;
|
|
91
|
-
let chatEnabled = false;
|
|
92
|
-
let chatReason = "";
|
|
93
|
-
let chatBusy = false;
|
|
94
|
-
let chatStream = null;
|
|
95
|
-
let pendingUser = "";
|
|
96
|
-
const toolCards = new Map();
|
|
97
|
-
let mermaidSeq = 0;
|
|
98
|
-
let mermaidLoading = null;
|
|
99
|
-
|
|
100
|
-
function ensureMermaid() {
|
|
101
|
-
if (window.mermaid) return Promise.resolve(window.mermaid);
|
|
102
|
-
if (!mermaidLoading) {
|
|
103
|
-
mermaidLoading = new Promise((resolve, reject) => {
|
|
104
|
-
const script = document.createElement("script");
|
|
105
|
-
script.src = "/vendor/mermaid.min.js";
|
|
106
|
-
script.async = true;
|
|
107
|
-
script.onload = () => {
|
|
108
|
-
if (!window.mermaid) return reject(new Error("Mermaid failed to initialize"));
|
|
109
|
-
window.mermaid.initialize({ startOnLoad: false, securityLevel: "strict", theme: "dark" });
|
|
110
|
-
resolve(window.mermaid);
|
|
111
|
-
};
|
|
112
|
-
script.onerror = () => reject(new Error("Mermaid failed to load"));
|
|
113
|
-
document.head.append(script);
|
|
114
|
-
});
|
|
115
|
-
}
|
|
116
|
-
return mermaidLoading;
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
async function renderMermaidInto(container) {
|
|
120
|
-
if (!container || !container.querySelector("pre.diagram")) return;
|
|
121
|
-
try { await ensureMermaid(); } catch { return; }
|
|
122
|
-
for (const pre of container.querySelectorAll("pre.diagram")) {
|
|
123
|
-
if (pre.dataset.rendered === "1") continue;
|
|
124
|
-
const code = pre.textContent || "";
|
|
125
|
-
if (!code.trim()) continue;
|
|
126
|
-
try {
|
|
127
|
-
const { svg } = await window.mermaid.render("mmd" + (++mermaidSeq), code);
|
|
128
|
-
pre.innerHTML = svg;
|
|
129
|
-
pre.dataset.rendered = "1";
|
|
130
|
-
pre.classList.add("rendered");
|
|
131
|
-
} catch {
|
|
132
|
-
pre.classList.add("mermaid-error");
|
|
133
|
-
}
|
|
134
|
-
}
|
|
135
|
-
}
|
|
136
|
-
let theoryNodesCache = null;
|
|
137
|
-
let activeTheoryNode = "";
|
|
138
|
-
let activeView = "study";
|
|
139
|
-
let activeLayout = "triptych";
|
|
140
|
-
|
|
141
|
-
function setConnection(kind) {
|
|
142
|
-
el.connection.classList.remove("live", "down");
|
|
143
|
-
if (kind === "live") el.connection.classList.add("live");
|
|
144
|
-
if (kind === "down") el.connection.classList.add("down");
|
|
145
|
-
el.connection.title = kind === "live" ? "live" : kind === "down" ? "reconnecting" : "connecting";
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
function showNotice(text) {
|
|
149
|
-
if (!text) {
|
|
150
|
-
el.notice.hidden = true;
|
|
151
|
-
el.notice.textContent = "";
|
|
152
|
-
return;
|
|
153
|
-
}
|
|
154
|
-
el.notice.hidden = false;
|
|
155
|
-
el.notice.textContent = text;
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
function only(card) {
|
|
159
|
-
for (const c of [el.loading, el.idle, el.question, el.feedback, el.teach, el.mock]) {
|
|
160
|
-
if (c) c.hidden = c !== card;
|
|
161
|
-
}
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
function setPhase(phase) {
|
|
165
|
-
el.phasePill.textContent = phase || "idle";
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
function safeHttpUrl(value) {
|
|
169
|
-
try {
|
|
170
|
-
const u = new URL(value);
|
|
171
|
-
return u.protocol === "http:" || u.protocol === "https:" ? u.href : "";
|
|
172
|
-
} catch {
|
|
173
|
-
return "";
|
|
174
|
-
}
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
function imageSrc(im) {
|
|
178
|
-
const src = String(im?.src || "");
|
|
179
|
-
if (/^https?:\/\//i.test(src)) return "/api/asset?src=" + encodeURIComponent(src);
|
|
180
|
-
if (src.startsWith("/assets/")) return src;
|
|
181
|
-
return "";
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
function renderImages(images) {
|
|
185
|
-
el.images.replaceChildren();
|
|
186
|
-
for (const im of images || []) {
|
|
187
|
-
const src = imageSrc(im);
|
|
188
|
-
if (!src) continue;
|
|
189
|
-
const fig = document.createElement("figure");
|
|
190
|
-
fig.className = "exhibit";
|
|
191
|
-
const img = document.createElement("img");
|
|
192
|
-
img.src = src;
|
|
193
|
-
img.alt = im.label || "exhibit";
|
|
194
|
-
img.loading = "lazy";
|
|
195
|
-
img.addEventListener("click", () => window.open(img.src, "_blank", "noopener"));
|
|
196
|
-
const cap = document.createElement("figcaption");
|
|
197
|
-
cap.textContent = im.label || "exhibit";
|
|
198
|
-
fig.append(img, cap);
|
|
199
|
-
el.images.append(fig);
|
|
200
|
-
}
|
|
201
|
-
el.images.hidden = !el.images.children.length;
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
function renderOptions(q, readOnly) {
|
|
205
|
-
el.options.replaceChildren();
|
|
206
|
-
for (const opt of q.options || []) {
|
|
207
|
-
const b = document.createElement("button");
|
|
208
|
-
b.type = "button";
|
|
209
|
-
b.className = "option";
|
|
210
|
-
b.dataset.value = opt.value;
|
|
211
|
-
b.disabled = readOnly;
|
|
212
|
-
b.setAttribute("aria-pressed", selected.has(opt.value) ? "true" : "false");
|
|
213
|
-
if (selected.has(opt.value)) b.classList.add("selected");
|
|
214
|
-
const key = document.createElement("span");
|
|
215
|
-
key.className = "key";
|
|
216
|
-
key.textContent = opt.value;
|
|
217
|
-
const text = document.createElement("span");
|
|
218
|
-
text.textContent = opt.text || "(image option)";
|
|
219
|
-
b.append(key, text);
|
|
220
|
-
b.addEventListener("click", () => toggleOption(opt.value));
|
|
221
|
-
el.options.append(b);
|
|
222
|
-
}
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
function toggleOption(value) {
|
|
226
|
-
if (!state || state.phase !== "question" || !state.q) return;
|
|
227
|
-
if (state.q.multi) {
|
|
228
|
-
if (selected.has(value)) selected.delete(value);
|
|
229
|
-
else selected.add(value);
|
|
230
|
-
} else {
|
|
231
|
-
selected = new Set([value]);
|
|
232
|
-
}
|
|
233
|
-
updateSelectionUI();
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
function updateSelectionUI() {
|
|
237
|
-
const multi = state?.q?.multi === true;
|
|
238
|
-
for (const b of el.options.querySelectorAll(".option")) {
|
|
239
|
-
const on = selected.has(b.dataset.value);
|
|
240
|
-
b.classList.toggle("selected", on);
|
|
241
|
-
b.setAttribute("aria-pressed", on ? "true" : "false");
|
|
242
|
-
}
|
|
243
|
-
for (const b of el.confidenceButtons.querySelectorAll("button")) {
|
|
244
|
-
b.classList.toggle("active", Number(b.dataset.confidence) === confidence);
|
|
245
|
-
}
|
|
246
|
-
el.submit.disabled = !(state?.phase === "question" && selected.size > 0);
|
|
247
|
-
el.submit.textContent = multi ? "Submit selection" : "Submit answer";
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
function startTimer(key) {
|
|
251
|
-
if (timerHandle) clearInterval(timerHandle);
|
|
252
|
-
timerHandle = null;
|
|
253
|
-
if (key !== questionKey) {
|
|
254
|
-
questionKey = key;
|
|
255
|
-
startedAt = Date.now();
|
|
256
|
-
confidence = 2;
|
|
257
|
-
selected = new Set();
|
|
258
|
-
}
|
|
259
|
-
const tick = () => {
|
|
260
|
-
if (!state || state.phase !== "question") return;
|
|
261
|
-
const deadline = Number(state.meta?.mock?.deadline) || 0;
|
|
262
|
-
if (deadline) {
|
|
263
|
-
const left = Math.max(0, Math.floor((deadline - Date.now()) / 1000));
|
|
264
|
-
el.timer.textContent = `${Math.floor(left / 60)}:${String(left % 60).padStart(2, "0")} left`;
|
|
265
|
-
return;
|
|
266
|
-
}
|
|
267
|
-
const s = Math.max(0, Math.floor((Date.now() - startedAt) / 1000));
|
|
268
|
-
el.timer.textContent = `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`;
|
|
269
|
-
};
|
|
270
|
-
tick();
|
|
271
|
-
timerHandle = setInterval(tick, 1000);
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
function stopTimer() {
|
|
275
|
-
if (timerHandle) clearInterval(timerHandle);
|
|
276
|
-
timerHandle = null;
|
|
277
|
-
if (state) el.timer.textContent = "";
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
function setStemSize(node, text) {
|
|
281
|
-
const len = String(text || "").length;
|
|
282
|
-
node.classList.remove("stem-long", "stem-xlong");
|
|
283
|
-
if (len > 420) node.classList.add("stem-xlong");
|
|
284
|
-
else if (len > 220) node.classList.add("stem-long");
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
function renderQuestion(s, readOnly) {
|
|
288
|
-
only(el.question);
|
|
289
|
-
const q = s.q || {};
|
|
290
|
-
setPhase(s.phase);
|
|
291
|
-
const mockMeta = s.meta?.mock ? `mock ${(s.meta.mock.answered || 0) + 1}/${s.meta.mock.total || "?"}` : "";
|
|
292
|
-
const probeMeta = s.meta?.probe ? `probe ${s.meta.probe.answered || 1}/${s.meta.probe.total || 20}` : "";
|
|
293
|
-
el.qmeta.textContent = [q.domain, q.node, q.index ? `#${q.index}` : "", mockMeta || probeMeta].filter(Boolean).join(" · ");
|
|
294
|
-
el.stem.textContent = q.stem || "";
|
|
295
|
-
setStemSize(el.stem, q.stem || "");
|
|
296
|
-
el.questionNote.hidden = !readOnly;
|
|
297
|
-
el.questionNote.textContent = readOnly ? "Submitted; grading..." : "";
|
|
298
|
-
renderImages(q.images);
|
|
299
|
-
renderOptions(q, readOnly);
|
|
300
|
-
updateSelectionUI();
|
|
301
|
-
if (!readOnly) startTimer(`${q.id}:${s.rev}`);
|
|
302
|
-
else stopTimer();
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
function renderFeedback(s) {
|
|
306
|
-
only(el.feedback);
|
|
307
|
-
setPhase(s.phase);
|
|
308
|
-
stopTimer();
|
|
309
|
-
const f = s.feedback || {};
|
|
310
|
-
el.verdict.textContent = f.correct ? "Correct" : "Incorrect";
|
|
311
|
-
el.verdict.className = "verdict " + (f.correct ? "good" : "bad");
|
|
312
|
-
el.feedbackMeta.textContent = [f.box != null ? `box ${f.box}` : "", f.due ? `due ${String(f.due).slice(0, 10)}` : "", s.meta?.readiness || ""].filter(Boolean).join(" · ");
|
|
313
|
-
el.feedbackTitle.textContent = s.q?.stem || "Feedback";
|
|
314
|
-
setStemSize(el.feedbackTitle, s.q?.stem || "Feedback");
|
|
315
|
-
el.answerLine.textContent = `Answer: ${(f.answer || []).join(", ") || "?"} · you: ${(f.selected || []).join(", ") || "?"}`;
|
|
316
|
-
el.explanation.textContent = f.explanation || "";
|
|
317
|
-
const src = safeHttpUrl(f.source || "");
|
|
318
|
-
el.source.hidden = !src;
|
|
319
|
-
el.source.href = src || "#";
|
|
320
|
-
el.source.textContent = src || "";
|
|
321
|
-
}
|
|
322
|
-
|
|
323
|
-
function renderTeach(s) {
|
|
324
|
-
only(el.teach);
|
|
325
|
-
setPhase(s.phase);
|
|
326
|
-
stopTimer();
|
|
327
|
-
const t = s.teach || {};
|
|
328
|
-
el.teachMeta.textContent = [t.domain, t.reason].filter(Boolean).join(" · ");
|
|
329
|
-
el.teachTitle.textContent = t.title || t.node || "Teach";
|
|
330
|
-
el.teachSummary.textContent = t.summary || "";
|
|
331
|
-
el.teachTitle.classList.remove("stem-long", "stem-xlong");
|
|
332
|
-
el.teachSection.replaceChildren();
|
|
333
|
-
renderMarkdownInto(el.teachSection, t.section || "");
|
|
334
|
-
const doc = safeHttpUrl(t.docUrl || "");
|
|
335
|
-
el.teachDoc.hidden = !doc;
|
|
336
|
-
el.teachDoc.href = doc || "#";
|
|
337
|
-
el.teachDoc.textContent = doc || "";
|
|
338
|
-
}
|
|
339
|
-
|
|
340
|
-
function renderMock(s) {
|
|
341
|
-
only(el.mock);
|
|
342
|
-
setPhase("mock_summary");
|
|
343
|
-
stopTimer();
|
|
344
|
-
const m = s.mock || {};
|
|
345
|
-
el.mockMeta.textContent = `${m.correct || 0}/${m.completed ?? m.total ?? 0} correct · ${m.completed ?? m.total ?? 0}/${m.requested ?? m.total ?? 0} completed`;
|
|
346
|
-
el.mockTitle.textContent = m.score != null ? `Mock score: ${Math.round(m.score * 100)}%` : "Mock complete";
|
|
347
|
-
el.mockNote.textContent = `${m.timedOut ? "Time expired. " : ""}Requested ${m.requested ?? "?"} questions; completed ${m.completed ?? m.total ?? 0}. Feedback is shown now that the run is over. progress.json was not modified.`;
|
|
348
|
-
el.mockResults.replaceChildren();
|
|
349
|
-
for (const a of m.answers || []) {
|
|
350
|
-
const row = document.createElement("div");
|
|
351
|
-
row.className = "mock-row";
|
|
352
|
-
const mark = document.createElement("span");
|
|
353
|
-
mark.className = a.correct ? "ok" : "bad";
|
|
354
|
-
mark.textContent = a.correct ? "OK" : "MISS";
|
|
355
|
-
const q = document.createElement("span");
|
|
356
|
-
q.className = "q";
|
|
357
|
-
q.textContent = `${a.qid} · ${a.domain || ""}/${a.node || ""} · you ${(a.selected || []).join(",")} · answer ${(a.answer || []).join(",")}`;
|
|
358
|
-
const secs = document.createElement("span");
|
|
359
|
-
secs.className = "muted";
|
|
360
|
-
secs.textContent = a.seconds != null ? `${a.seconds}s` : "";
|
|
361
|
-
row.append(mark, q, secs);
|
|
362
|
-
el.mockResults.append(row);
|
|
363
|
-
}
|
|
364
|
-
}
|
|
365
|
-
|
|
366
|
-
function renderIdle(s) {
|
|
367
|
-
only(el.idle);
|
|
368
|
-
setPhase("idle");
|
|
369
|
-
stopTimer();
|
|
370
|
-
el.idleNote.textContent = s.meta?.note || "Start a practice run when you are ready.";
|
|
371
|
-
}
|
|
372
|
-
|
|
373
|
-
function render(s) {
|
|
374
|
-
state = s;
|
|
375
|
-
showNotice("");
|
|
376
|
-
el.certLine.textContent = `${s.cert || "cert"} · ${s.mode || "practice"}`;
|
|
377
|
-
el.readiness.textContent = s.meta?.readiness ? `readiness ${s.meta.readiness}` : "";
|
|
378
|
-
const phase = s.phase || "idle";
|
|
379
|
-
if (phase === "question") renderQuestion(s, false);
|
|
380
|
-
else if (phase === "submitted") renderQuestion(s, true);
|
|
381
|
-
else if (phase === "feedback") renderFeedback(s);
|
|
382
|
-
else if (phase === "teach") renderTeach(s);
|
|
383
|
-
else if (phase === "mock_summary") renderMock(s);
|
|
384
|
-
else renderIdle(s);
|
|
385
|
-
if (activeLayout === "command") void updateCommandStrip();
|
|
386
|
-
}
|
|
387
|
-
|
|
388
|
-
async function api(path, body) {
|
|
389
|
-
const res = await fetch(path, {
|
|
390
|
-
method: body === undefined ? "GET" : "POST",
|
|
391
|
-
credentials: "same-origin",
|
|
392
|
-
headers: body === undefined ? {} : { "content-type": "application/json" },
|
|
393
|
-
body: body === undefined ? undefined : JSON.stringify(body),
|
|
394
|
-
});
|
|
395
|
-
let data = {};
|
|
396
|
-
try { data = await res.json(); } catch { /* non-json */ }
|
|
397
|
-
if (!res.ok) {
|
|
398
|
-
if (res.status === 401) showNotice("Session expired. Reopen the token URL printed by the server.");
|
|
399
|
-
if (res.status === 409) await refresh();
|
|
400
|
-
throw new Error(data.error || `HTTP ${res.status}`);
|
|
401
|
-
}
|
|
402
|
-
return data;
|
|
403
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Study console wiring: boot, fetch, submit/next, event stream, and input
|
|
3
|
+
* binding. Presentation lives in the sibling spa-*.js modules; this file is the
|
|
4
|
+
* only place that talks to the server and the only place that wires listeners.
|
|
5
|
+
*/
|
|
6
|
+
import { el } from "./spa-dom.js";
|
|
7
|
+
import { store } from "./spa-store.js";
|
|
8
|
+
import { api } from "./spa-api.js";
|
|
9
|
+
import { only, setConnection, showNotice, setLayout, appendStatus } from "./spa-ui.js";
|
|
10
|
+
import { renderState, toggleOption, updateSelectionUI } from "./spa-question.js";
|
|
11
|
+
import { setView, updateCommandStrip } from "./spa-views.js";
|
|
12
|
+
import { handleChatEvent, renderChatHistory, sendChat, abortChat, syncChatControls } from "./spa-chat.js";
|
|
404
13
|
|
|
405
14
|
async function refresh() {
|
|
406
|
-
|
|
407
|
-
render(s);
|
|
408
|
-
}
|
|
409
|
-
|
|
410
|
-
async function submitAnswer() {
|
|
411
|
-
if (!state || state.phase !== "question" || !state.q || !selected.size) return;
|
|
412
|
-
el.submit.disabled = true;
|
|
413
|
-
try {
|
|
414
|
-
const seconds = Math.max(1, Math.round((Date.now() - startedAt) / 1000));
|
|
415
|
-
const data = await api("/api/answer", {
|
|
416
|
-
runId: state.runId,
|
|
417
|
-
rev: state.rev,
|
|
418
|
-
qid: state.q.id,
|
|
419
|
-
selected: [...selected],
|
|
420
|
-
confidence,
|
|
421
|
-
seconds,
|
|
422
|
-
});
|
|
423
|
-
render(data.state || await api("/api/state"));
|
|
424
|
-
} catch (e) {
|
|
425
|
-
showNotice(e.message);
|
|
426
|
-
try { await refresh(); } catch { /* keep notice */ }
|
|
427
|
-
}
|
|
428
|
-
}
|
|
429
|
-
|
|
430
|
-
async function nextQuestion() {
|
|
431
|
-
if (!state || !["feedback", "teach"].includes(state.phase)) return;
|
|
432
|
-
try {
|
|
433
|
-
const data = await api("/api/next", { runId: state.runId, rev: state.rev });
|
|
434
|
-
render(data.state || await api("/api/state"));
|
|
435
|
-
} catch (e) {
|
|
436
|
-
showNotice(e.message);
|
|
437
|
-
try { await refresh(); } catch { /* keep notice */ }
|
|
438
|
-
}
|
|
15
|
+
renderState(await api("/api/state"));
|
|
439
16
|
}
|
|
440
17
|
|
|
18
|
+
/** Enter a study mode: practice, probe, or timed mock. */
|
|
441
19
|
async function startPractice(mode = "practice") {
|
|
442
20
|
el.start.disabled = true;
|
|
443
21
|
try {
|
|
@@ -447,7 +25,7 @@ async function startPractice(mode = "practice") {
|
|
|
447
25
|
? { mode: "probe", probe_size: 20 }
|
|
448
26
|
: { mode: "practice" };
|
|
449
27
|
const data = await api("/api/start", body);
|
|
450
|
-
|
|
28
|
+
renderState(data.state || await api("/api/state"));
|
|
451
29
|
} catch (e) {
|
|
452
30
|
showNotice(e.message);
|
|
453
31
|
try { await refresh(); } catch { /* keep notice */ }
|
|
@@ -456,599 +34,74 @@ async function startPractice(mode = "practice") {
|
|
|
456
34
|
}
|
|
457
35
|
}
|
|
458
36
|
|
|
459
|
-
function
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
let last = 0;
|
|
463
|
-
for (const m of text.matchAll(re)) {
|
|
464
|
-
if (m.index > last) frag.append(document.createTextNode(text.slice(last, m.index)));
|
|
465
|
-
const tok = m[0];
|
|
466
|
-
if (tok.startsWith("`")) {
|
|
467
|
-
const code = document.createElement("code");
|
|
468
|
-
code.textContent = tok.slice(1, -1);
|
|
469
|
-
frag.append(code);
|
|
470
|
-
} else if (tok.startsWith("**")) {
|
|
471
|
-
const b = document.createElement("strong");
|
|
472
|
-
b.textContent = tok.slice(2, -2);
|
|
473
|
-
frag.append(b);
|
|
474
|
-
} else {
|
|
475
|
-
const mm = tok.match(/^\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)$/);
|
|
476
|
-
const a = document.createElement("a");
|
|
477
|
-
a.textContent = mm?.[1] || tok;
|
|
478
|
-
a.href = safeHttpUrl(mm?.[2] || "");
|
|
479
|
-
a.target = "_blank";
|
|
480
|
-
a.rel = "noreferrer";
|
|
481
|
-
frag.append(a);
|
|
482
|
-
}
|
|
483
|
-
last = m.index + tok.length;
|
|
484
|
-
}
|
|
485
|
-
if (last < text.length) frag.append(document.createTextNode(text.slice(last)));
|
|
486
|
-
return frag;
|
|
487
|
-
}
|
|
488
|
-
|
|
489
|
-
function markdownFragment(text) {
|
|
490
|
-
const frag = document.createDocumentFragment();
|
|
491
|
-
const lines = String(text || "").split(/\r?\n/);
|
|
492
|
-
let i = 0;
|
|
493
|
-
let para = [];
|
|
494
|
-
const flush = () => {
|
|
495
|
-
if (!para.length) return;
|
|
496
|
-
const p = document.createElement("p");
|
|
497
|
-
p.append(inlineNodes(para.join(" ")));
|
|
498
|
-
frag.append(p);
|
|
499
|
-
para = [];
|
|
500
|
-
};
|
|
501
|
-
while (i < lines.length) {
|
|
502
|
-
const line = lines[i];
|
|
503
|
-
if (/^```/.test(line)) {
|
|
504
|
-
flush();
|
|
505
|
-
const lang = line.slice(3).trim();
|
|
506
|
-
i++;
|
|
507
|
-
const code = [];
|
|
508
|
-
while (i < lines.length && !/^```/.test(lines[i])) code.push(lines[i++]);
|
|
509
|
-
if (i < lines.length) i++;
|
|
510
|
-
const pre = document.createElement("pre");
|
|
511
|
-
if (lang === "mermaid") pre.className = "diagram";
|
|
512
|
-
const c = document.createElement("code");
|
|
513
|
-
c.textContent = code.join("\n");
|
|
514
|
-
pre.append(c);
|
|
515
|
-
frag.append(pre);
|
|
516
|
-
continue;
|
|
517
|
-
}
|
|
518
|
-
const h = line.match(/^(#{1,3})\s+(.*)$/);
|
|
519
|
-
if (h) {
|
|
520
|
-
flush();
|
|
521
|
-
const elh = document.createElement("h" + h[1].length);
|
|
522
|
-
elh.append(inlineNodes(h[2]));
|
|
523
|
-
frag.append(elh);
|
|
524
|
-
i++;
|
|
525
|
-
continue;
|
|
526
|
-
}
|
|
527
|
-
if (/^\s*[-*]\s+/.test(line)) {
|
|
528
|
-
flush();
|
|
529
|
-
const ul = document.createElement("ul");
|
|
530
|
-
while (i < lines.length && /^\s*[-*]\s+/.test(lines[i])) {
|
|
531
|
-
const li = document.createElement("li");
|
|
532
|
-
li.append(inlineNodes(lines[i].replace(/^\s*[-*]\s+/, "")));
|
|
533
|
-
ul.append(li);
|
|
534
|
-
i++;
|
|
535
|
-
}
|
|
536
|
-
frag.append(ul);
|
|
537
|
-
continue;
|
|
538
|
-
}
|
|
539
|
-
if (/^>\s?/.test(line)) {
|
|
540
|
-
flush();
|
|
541
|
-
const quote = document.createElement("blockquote");
|
|
542
|
-
quote.append(inlineNodes(line.replace(/^>\s?/, "")));
|
|
543
|
-
frag.append(quote);
|
|
544
|
-
i++;
|
|
545
|
-
continue;
|
|
546
|
-
}
|
|
547
|
-
if (!line.trim()) {
|
|
548
|
-
flush();
|
|
549
|
-
i++;
|
|
550
|
-
continue;
|
|
551
|
-
}
|
|
552
|
-
para.push(line);
|
|
553
|
-
i++;
|
|
554
|
-
}
|
|
555
|
-
flush();
|
|
556
|
-
return frag;
|
|
557
|
-
}
|
|
558
|
-
|
|
559
|
-
function renderMarkdownInto(node, text) {
|
|
560
|
-
node.replaceChildren(markdownFragment(text));
|
|
561
|
-
void renderMermaidInto(node);
|
|
562
|
-
}
|
|
563
|
-
|
|
564
|
-
function chatScroll(force = false) {
|
|
565
|
-
if (!el.chatLog) return;
|
|
566
|
-
const near = el.chatLog.scrollHeight - el.chatLog.scrollTop - el.chatLog.clientHeight < 80;
|
|
567
|
-
if (force || near) el.chatLog.scrollTop = el.chatLog.scrollHeight;
|
|
568
|
-
}
|
|
569
|
-
|
|
570
|
-
function appendChatMessage(role, text) {
|
|
571
|
-
const div = document.createElement("div");
|
|
572
|
-
div.className = "chat-msg " + (role === "user" ? "user" : "assistant");
|
|
573
|
-
if (role === "assistant") renderMarkdownInto(div, text);
|
|
574
|
-
else div.textContent = text;
|
|
575
|
-
el.chatLog.append(div);
|
|
576
|
-
chatScroll();
|
|
577
|
-
return div;
|
|
578
|
-
}
|
|
579
|
-
|
|
580
|
-
function setChatStatus(kind, text) {
|
|
581
|
-
el.chatStatus.className = "chat-status" + (kind === "live" ? " live" : kind === "busy" ? " busy" : "");
|
|
582
|
-
el.chatStatus.textContent = text;
|
|
583
|
-
}
|
|
584
|
-
|
|
585
|
-
function renderChatHistory(messages) {
|
|
586
|
-
el.chatLog.replaceChildren();
|
|
587
|
-
for (const m of messages || []) {
|
|
588
|
-
if (m.role === "user" || m.role === "assistant") appendChatMessage(m.role, m.text);
|
|
589
|
-
}
|
|
590
|
-
if (!el.chatLog.children.length) {
|
|
591
|
-
const empty = document.createElement("div");
|
|
592
|
-
empty.className = "chat-empty";
|
|
593
|
-
empty.textContent = chatEnabled
|
|
594
|
-
? "Ask about the current question, a concept, or why an answer is right. The coach teaches; the quiz card grades."
|
|
595
|
-
: (chatReason || "Teaching chat is disabled. Start the server with --pi <pi|path> or set PI_BIN.");
|
|
596
|
-
el.chatLog.append(empty);
|
|
597
|
-
}
|
|
598
|
-
}
|
|
599
|
-
|
|
600
|
-
function renderTool(event) {
|
|
601
|
-
let card = toolCards.get(event.id);
|
|
602
|
-
if (event.status === "start" || !card) {
|
|
603
|
-
card = document.createElement("div");
|
|
604
|
-
card.className = "chat-tool";
|
|
605
|
-
const head = document.createElement("div");
|
|
606
|
-
head.className = "tool-head";
|
|
607
|
-
const name = document.createElement("span");
|
|
608
|
-
name.textContent = event.name || "tool";
|
|
609
|
-
const state = document.createElement("span");
|
|
610
|
-
state.className = "muted";
|
|
611
|
-
head.append(name, state);
|
|
612
|
-
const body = document.createElement("pre");
|
|
613
|
-
body.className = "tool-body";
|
|
614
|
-
card.append(head, body);
|
|
615
|
-
el.chatLog.append(card);
|
|
616
|
-
toolCards.set(event.id, card);
|
|
617
|
-
}
|
|
618
|
-
const state = card.querySelector(".tool-head .muted");
|
|
619
|
-
const body = card.querySelector(".tool-body");
|
|
620
|
-
if (event.status === "end") {
|
|
621
|
-
state.textContent = event.isError ? "error" : "done";
|
|
622
|
-
if (event.isError) card.classList.add("error");
|
|
623
|
-
body.textContent = (event.text || "").slice(0, 3000);
|
|
624
|
-
} else {
|
|
625
|
-
state.textContent = event.status === "start" ? "running" : "…";
|
|
626
|
-
if (event.partial) body.textContent = JSON.stringify(event.partial).slice(0, 1000);
|
|
627
|
-
else if (event.args) body.textContent = JSON.stringify(event.args).slice(0, 1000);
|
|
628
|
-
}
|
|
629
|
-
chatScroll();
|
|
630
|
-
}
|
|
631
|
-
|
|
632
|
-
function handleChatEvent(event) {
|
|
633
|
-
switch (event.type) {
|
|
634
|
-
case "chat.user": {
|
|
635
|
-
if (pendingUser && pendingUser === event.text) pendingUser = "";
|
|
636
|
-
else appendChatMessage("user", event.text);
|
|
637
|
-
break;
|
|
638
|
-
}
|
|
639
|
-
case "chat.start": {
|
|
640
|
-
chatBusy = true;
|
|
641
|
-
el.chatStop.hidden = false;
|
|
642
|
-
el.chatSend.disabled = true;
|
|
643
|
-
setChatStatus("busy", "thinking");
|
|
644
|
-
chatStream = { div: appendChatMessage("assistant", ""), text: "" };
|
|
645
|
-
break;
|
|
646
|
-
}
|
|
647
|
-
case "chat.delta": {
|
|
648
|
-
if (!chatStream) chatStream = { div: appendChatMessage("assistant", ""), text: "" };
|
|
649
|
-
chatStream.text += event.text || "";
|
|
650
|
-
chatStream.div.textContent = chatStream.text;
|
|
651
|
-
chatScroll();
|
|
652
|
-
break;
|
|
653
|
-
}
|
|
654
|
-
case "chat.message": {
|
|
655
|
-
if (!chatStream) chatStream = { div: appendChatMessage("assistant", ""), text: "" };
|
|
656
|
-
renderMarkdownInto(chatStream.div, event.text || "");
|
|
657
|
-
chatStream = null;
|
|
658
|
-
chatScroll();
|
|
659
|
-
break;
|
|
660
|
-
}
|
|
661
|
-
case "chat.tool": {
|
|
662
|
-
renderTool(event);
|
|
663
|
-
break;
|
|
664
|
-
}
|
|
665
|
-
case "chat.done": {
|
|
666
|
-
chatBusy = false;
|
|
667
|
-
el.chatStop.hidden = true;
|
|
668
|
-
el.chatSend.disabled = !chatEnabled;
|
|
669
|
-
setChatStatus(chatEnabled ? "live" : "off", chatEnabled ? "ready" : "offline");
|
|
670
|
-
el.chatStatus.title = chatEnabled ? "Teaching chat ready" : (chatReason || "Teaching chat disabled");
|
|
671
|
-
if (!chatStream) break;
|
|
672
|
-
chatStream = null;
|
|
673
|
-
break;
|
|
674
|
-
}
|
|
675
|
-
case "chat.error": {
|
|
676
|
-
chatBusy = false;
|
|
677
|
-
el.chatStop.hidden = true;
|
|
678
|
-
el.chatSend.disabled = !chatEnabled;
|
|
679
|
-
setChatStatus("off", "error");
|
|
680
|
-
showNotice(event.error || "Chat failed.");
|
|
681
|
-
break;
|
|
682
|
-
}
|
|
683
|
-
default:
|
|
684
|
-
break;
|
|
685
|
-
}
|
|
686
|
-
}
|
|
687
|
-
|
|
688
|
-
async function sendChat() {
|
|
689
|
-
const text = el.chatInput.value.trim();
|
|
690
|
-
if (!text || !chatEnabled || chatBusy) return;
|
|
691
|
-
el.chatInput.value = "";
|
|
692
|
-
pendingUser = text;
|
|
693
|
-
appendChatMessage("user", text);
|
|
694
|
-
chatBusy = true;
|
|
695
|
-
el.chatSend.disabled = true;
|
|
696
|
-
el.chatStop.hidden = false;
|
|
697
|
-
setChatStatus("busy", "thinking");
|
|
37
|
+
async function submitAnswer() {
|
|
38
|
+
if (!store.state || store.state.phase !== "question" || !store.state.q || !store.selected.size) return;
|
|
39
|
+
el.submit.disabled = true;
|
|
698
40
|
try {
|
|
699
|
-
const
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
41
|
+
const seconds = Math.max(1, Math.round((Date.now() - store.startedAt) / 1000));
|
|
42
|
+
const data = await api("/api/answer", {
|
|
43
|
+
runId: store.state.runId,
|
|
44
|
+
rev: store.state.rev,
|
|
45
|
+
qid: store.state.q.id,
|
|
46
|
+
selected: [...store.selected],
|
|
47
|
+
confidence: store.confidence,
|
|
48
|
+
seconds,
|
|
49
|
+
});
|
|
50
|
+
renderState(data.state || await api("/api/state"));
|
|
706
51
|
} catch (e) {
|
|
707
|
-
chatBusy = false;
|
|
708
|
-
pendingUser = "";
|
|
709
|
-
el.chatStop.hidden = true;
|
|
710
|
-
el.chatSend.disabled = !chatEnabled;
|
|
711
|
-
setChatStatus("off", "error");
|
|
712
52
|
showNotice(e.message);
|
|
53
|
+
try { await refresh(); } catch { /* keep notice */ }
|
|
713
54
|
}
|
|
714
55
|
}
|
|
715
56
|
|
|
716
|
-
async function
|
|
57
|
+
async function nextQuestion() {
|
|
58
|
+
if (!store.state || !["feedback", "teach"].includes(store.state.phase)) return;
|
|
717
59
|
try {
|
|
718
|
-
await api("/api/
|
|
60
|
+
const data = await api("/api/next", { runId: store.state.runId, rev: store.state.rev });
|
|
61
|
+
renderState(data.state || await api("/api/state"));
|
|
719
62
|
} catch (e) {
|
|
720
63
|
showNotice(e.message);
|
|
64
|
+
try { await refresh(); } catch { /* keep notice */ }
|
|
721
65
|
}
|
|
722
66
|
}
|
|
723
67
|
|
|
724
|
-
function
|
|
725
|
-
activeLayout = ["triptych", "command"].includes(name) ? name : "triptych";
|
|
726
|
-
el.app.dataset.layout = activeLayout;
|
|
727
|
-
for (const b of el.layoutButtons) b.classList.toggle("active", b.dataset.appLayout === activeLayout);
|
|
728
|
-
localStorage.setItem("certCoachLayout", activeLayout);
|
|
729
|
-
if (activeLayout === "command") void updateCommandStrip();
|
|
730
|
-
}
|
|
731
|
-
|
|
732
|
-
async function updateCommandStrip() {
|
|
68
|
+
async function loadCertPicker(current) {
|
|
733
69
|
try {
|
|
734
|
-
const data = await api("/api/
|
|
735
|
-
|
|
736
|
-
const
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
el.cmdDue.textContent = String(d.due || 0);
|
|
743
|
-
const weak = (p.weakNodes || [])[0];
|
|
744
|
-
el.cmdWeak.textContent = weak?.domain || "—";
|
|
745
|
-
el.cmdWeakNote.textContent = weak ? `${weak.title} · ${Math.round((weak.accuracy || 0) * 100)}%` : "No attempts yet";
|
|
746
|
-
el.cmdReadinessBar.style.setProperty("--value", `${readiness}%`);
|
|
747
|
-
el.cmdCoverageBar.style.setProperty("--value", `${coverage}%`);
|
|
748
|
-
el.cmdDomains.replaceChildren();
|
|
749
|
-
for (const dom of p.domains || []) {
|
|
750
|
-
const row = document.createElement("div");
|
|
751
|
-
row.className = "domain-row";
|
|
752
|
-
const name = document.createElement("span");
|
|
753
|
-
name.textContent = dom.name;
|
|
754
|
-
const meter = document.createElement("div");
|
|
755
|
-
meter.className = "domain-meter";
|
|
756
|
-
const fill = document.createElement("i");
|
|
757
|
-
fill.style.setProperty("--value", `${Math.round((dom.accuracy || 0) * 100)}%`);
|
|
758
|
-
meter.append(fill);
|
|
759
|
-
const val = document.createElement("b");
|
|
760
|
-
val.textContent = `${Math.round((dom.accuracy || 0) * 100)}%`;
|
|
761
|
-
row.append(name, meter, val);
|
|
762
|
-
el.cmdDomains.append(row);
|
|
70
|
+
const data = await api("/api/certs");
|
|
71
|
+
el.certSelect.replaceChildren();
|
|
72
|
+
for (const c of data.certs || []) {
|
|
73
|
+
const opt = document.createElement("option");
|
|
74
|
+
opt.value = c.code;
|
|
75
|
+
opt.textContent = c.title ? `${c.code} — ${c.title}` : c.code;
|
|
76
|
+
if (c.code === current) opt.selected = true;
|
|
77
|
+
el.certSelect.append(opt);
|
|
763
78
|
}
|
|
79
|
+
el.certSelect.disabled = (data.certs || []).length < 2;
|
|
764
80
|
} catch {
|
|
765
|
-
|
|
766
|
-
}
|
|
767
|
-
}
|
|
768
|
-
|
|
769
|
-
function setView(name) {
|
|
770
|
-
activeView = name;
|
|
771
|
-
for (const b of document.querySelectorAll(".nav button, .side-nav button")) b.classList.toggle("active", b.dataset.tab === name);
|
|
772
|
-
el.studyView.hidden = name !== "study";
|
|
773
|
-
el.pathView.hidden = name !== "path";
|
|
774
|
-
el.dashboardView.hidden = name !== "dashboard";
|
|
775
|
-
el.theoryView.hidden = name !== "theory";
|
|
776
|
-
el.logsView.hidden = name !== "logs";
|
|
777
|
-
if (name === "path") loadPath();
|
|
778
|
-
if (name === "dashboard") loadDashboard();
|
|
779
|
-
if (name === "theory") loadTheory();
|
|
780
|
-
if (name === "logs") loadLogs();
|
|
781
|
-
}
|
|
782
|
-
|
|
783
|
-
function stat(k, v) {
|
|
784
|
-
const d = document.createElement("div");
|
|
785
|
-
d.className = "stat";
|
|
786
|
-
const kk = document.createElement("div");
|
|
787
|
-
kk.className = "k";
|
|
788
|
-
kk.textContent = k;
|
|
789
|
-
const vv = document.createElement("div");
|
|
790
|
-
vv.className = "v";
|
|
791
|
-
vv.textContent = String(v);
|
|
792
|
-
d.append(kk, vv);
|
|
793
|
-
return d;
|
|
794
|
-
}
|
|
795
|
-
|
|
796
|
-
async function loadPath() {
|
|
797
|
-
el.pathRoute.textContent = "Loading...";
|
|
798
|
-
el.pathDetail.textContent = "";
|
|
799
|
-
try {
|
|
800
|
-
const data = await api("/api/status");
|
|
801
|
-
const p = data.plan || {};
|
|
802
|
-
el.pathMeta.textContent = p.ready
|
|
803
|
-
? "target met"
|
|
804
|
-
: `probe ${Math.min(p.seenQuestions || 0, p.probeSize || 20)}/${p.probeSize || 20} · target ${Math.round((p.target?.readiness || 0.85) * 100)}% readiness / ${Math.round((p.target?.coverage || 0.8) * 100)}% coverage`;
|
|
805
|
-
el.pathRoute.replaceChildren();
|
|
806
|
-
const steps = document.createElement("div");
|
|
807
|
-
steps.className = "route-steps";
|
|
808
|
-
for (const step of p.steps || []) {
|
|
809
|
-
const d = document.createElement("div");
|
|
810
|
-
d.className = "route-step " + (step.state || "");
|
|
811
|
-
d.textContent = `${step.name} · ${step.state || ""}`;
|
|
812
|
-
steps.append(d);
|
|
813
|
-
}
|
|
814
|
-
el.pathRoute.append(steps);
|
|
815
|
-
const callout = document.createElement("div");
|
|
816
|
-
callout.className = "route-callout";
|
|
817
|
-
const phase = document.createElement("h3");
|
|
818
|
-
phase.textContent = p.ready ? "READY" : String(p.phase || "progress").toUpperCase();
|
|
819
|
-
const why = document.createElement("p");
|
|
820
|
-
why.textContent = p.why || "";
|
|
821
|
-
const next = document.createElement("p");
|
|
822
|
-
next.textContent = "Next: " + (p.next || "");
|
|
823
|
-
const criteria = document.createElement("p");
|
|
824
|
-
criteria.className = "muted";
|
|
825
|
-
criteria.textContent = p.criteria || "";
|
|
826
|
-
callout.append(phase, why, next, criteria);
|
|
827
|
-
el.pathRoute.append(callout);
|
|
828
|
-
|
|
829
|
-
el.pathDetail.replaceChildren();
|
|
830
|
-
for (const d of p.domains || []) {
|
|
831
|
-
const section = document.createElement("section");
|
|
832
|
-
section.className = "path-domain";
|
|
833
|
-
const h = document.createElement("h3");
|
|
834
|
-
h.textContent = `${d.name} · ${Math.round((d.weight_min || 0) * 100)}–${Math.round((d.weight_max || 0) * 100)}% of exam`;
|
|
835
|
-
const summary = document.createElement("p");
|
|
836
|
-
summary.className = "muted";
|
|
837
|
-
summary.textContent = `${d.seen}/${d.total} questions seen · ${d.accuracy == null ? "no accuracy yet" : `${Math.round(d.accuracy * 100)}% accuracy`} · coverage ${Math.round((d.coverage || 0) * 100)}%`;
|
|
838
|
-
section.append(h, summary);
|
|
839
|
-
if (d.skills?.length) {
|
|
840
|
-
const skills = document.createElement("ul");
|
|
841
|
-
skills.className = "path-skills";
|
|
842
|
-
for (const s of d.skills) { const li = document.createElement("li"); li.textContent = s; skills.append(li); }
|
|
843
|
-
section.append(skills);
|
|
844
|
-
}
|
|
845
|
-
const table = document.createElement("table");
|
|
846
|
-
table.className = "node-table";
|
|
847
|
-
const head = document.createElement("tr");
|
|
848
|
-
for (const label of ["Node / subdomain", "Status", "Questions", "Accuracy", "Box"]) {
|
|
849
|
-
const th = document.createElement("th"); th.textContent = label; head.append(th);
|
|
850
|
-
}
|
|
851
|
-
table.append(head);
|
|
852
|
-
for (const n of d.nodes || []) {
|
|
853
|
-
const tr = document.createElement("tr");
|
|
854
|
-
const title = document.createElement("td");
|
|
855
|
-
title.className = "path-node-title";
|
|
856
|
-
title.textContent = n.title;
|
|
857
|
-
const small = document.createElement("small");
|
|
858
|
-
small.textContent = n.summary || n.id;
|
|
859
|
-
title.append(small);
|
|
860
|
-
const status = document.createElement("td");
|
|
861
|
-
const tag = document.createElement("span");
|
|
862
|
-
tag.className = "status-tag " + (n.status || "unseen");
|
|
863
|
-
tag.textContent = n.status || "unseen";
|
|
864
|
-
status.append(tag);
|
|
865
|
-
const qs = document.createElement("td");
|
|
866
|
-
qs.className = "num";
|
|
867
|
-
qs.textContent = `${n.seen}/${n.total}`;
|
|
868
|
-
const acc = document.createElement("td");
|
|
869
|
-
acc.className = "num";
|
|
870
|
-
acc.textContent = n.accuracy == null ? "—" : `${Math.round(n.accuracy * 100)}%`;
|
|
871
|
-
const box = document.createElement("td");
|
|
872
|
-
box.className = "num";
|
|
873
|
-
box.textContent = String(n.box ?? 0);
|
|
874
|
-
tr.append(title, status, qs, acc, box);
|
|
875
|
-
table.append(tr);
|
|
876
|
-
}
|
|
877
|
-
section.append(table);
|
|
878
|
-
el.pathDetail.append(section);
|
|
879
|
-
}
|
|
880
|
-
} catch (e) {
|
|
881
|
-
el.pathRoute.textContent = e.message;
|
|
882
|
-
}
|
|
883
|
-
}
|
|
884
|
-
|
|
885
|
-
async function loadDashboard() {
|
|
886
|
-
el.dashboardBody.textContent = "Loading...";
|
|
887
|
-
try {
|
|
888
|
-
const data = await api("/api/status");
|
|
889
|
-
const d = data.dashboard || {};
|
|
890
|
-
const r = d.readiness || {};
|
|
891
|
-
el.dashboardMeta.textContent = `${d.title || ""} · ${d.verified || 0}/${d.total || 0} verified`;
|
|
892
|
-
el.dashboardBody.replaceChildren();
|
|
893
|
-
const grid = document.createElement("div");
|
|
894
|
-
grid.className = "dash-grid";
|
|
895
|
-
grid.append(
|
|
896
|
-
stat("Readiness", `${((r.value || 0) * 100).toFixed(1)}%`),
|
|
897
|
-
stat("Coverage", `${((r.coverage || 0) * 100).toFixed(0)}%`),
|
|
898
|
-
stat("Due now", d.due || 0),
|
|
899
|
-
stat("Gaps / review", `${d.boxes?.[1] || 0} / ${d.boxes?.[2] || 0}`),
|
|
900
|
-
stat("Mastered", d.boxes?.[3] || 0),
|
|
901
|
-
stat("Sessions", d.sessions || 0)
|
|
902
|
-
);
|
|
903
|
-
el.dashboardBody.append(grid);
|
|
904
|
-
|
|
905
|
-
const domains = document.createElement("div");
|
|
906
|
-
for (const [id, v] of Object.entries(r.by_domain || {})) {
|
|
907
|
-
const row = document.createElement("div");
|
|
908
|
-
row.className = "domain-row";
|
|
909
|
-
const name = document.createElement("span");
|
|
910
|
-
name.textContent = id;
|
|
911
|
-
const bar = document.createElement("div");
|
|
912
|
-
bar.className = "bar";
|
|
913
|
-
const fill = document.createElement("span");
|
|
914
|
-
fill.style.width = `${Math.round((v.accuracy || 0) * 100)}%`;
|
|
915
|
-
bar.append(fill);
|
|
916
|
-
const num = document.createElement("span");
|
|
917
|
-
num.className = "muted";
|
|
918
|
-
num.textContent = `${v.seen}/${v.total}${v.accuracy == null ? "" : ` · ${Math.round(v.accuracy * 100)}%`}`;
|
|
919
|
-
row.append(name, bar, num);
|
|
920
|
-
domains.append(row);
|
|
921
|
-
}
|
|
922
|
-
el.dashboardBody.append(domains);
|
|
923
|
-
if (d.weak?.length) {
|
|
924
|
-
const weak = document.createElement("div");
|
|
925
|
-
weak.className = "weak-list";
|
|
926
|
-
weak.textContent = "Weakest domains: " + d.weak.map((x) => `${x.domain} ${Math.round((x.accuracy || 0) * 100)}%`).join(" · ");
|
|
927
|
-
el.dashboardBody.append(weak);
|
|
928
|
-
}
|
|
929
|
-
} catch (e) {
|
|
930
|
-
el.dashboardBody.textContent = e.message;
|
|
931
|
-
}
|
|
932
|
-
}
|
|
933
|
-
|
|
934
|
-
async function loadTheory() {
|
|
935
|
-
try {
|
|
936
|
-
if (!theoryNodesCache) {
|
|
937
|
-
const data = await api("/api/theory");
|
|
938
|
-
theoryNodesCache = data.nodes || [];
|
|
939
|
-
el.theoryNodes.replaceChildren();
|
|
940
|
-
for (const n of theoryNodesCache) {
|
|
941
|
-
const b = document.createElement("button");
|
|
942
|
-
b.type = "button";
|
|
943
|
-
b.textContent = n.title || n.id;
|
|
944
|
-
b.dataset.node = n.id;
|
|
945
|
-
b.addEventListener("click", () => selectTheory(n.id));
|
|
946
|
-
el.theoryNodes.append(b);
|
|
947
|
-
}
|
|
948
|
-
}
|
|
949
|
-
const preferred = state?.q?.node || state?.teach?.node || theoryNodesCache[0]?.id;
|
|
950
|
-
if (preferred) await selectTheory(preferred);
|
|
951
|
-
} catch (e) {
|
|
952
|
-
el.theoryBody.textContent = e.message;
|
|
953
|
-
}
|
|
954
|
-
}
|
|
955
|
-
|
|
956
|
-
async function selectTheory(node) {
|
|
957
|
-
activeTheoryNode = node;
|
|
958
|
-
for (const b of el.theoryNodes.querySelectorAll("button")) b.classList.toggle("active", b.dataset.node === node);
|
|
959
|
-
el.theoryBody.textContent = "Loading...";
|
|
960
|
-
try {
|
|
961
|
-
const t = await api("/api/theory?node=" + encodeURIComponent(node));
|
|
962
|
-
el.theoryBody.replaceChildren();
|
|
963
|
-
const h = document.createElement("h2");
|
|
964
|
-
h.textContent = t.title || node;
|
|
965
|
-
const meta = document.createElement("p");
|
|
966
|
-
meta.className = "muted";
|
|
967
|
-
meta.textContent = [t.domain, t.summary].filter(Boolean).join(" · ");
|
|
968
|
-
el.theoryBody.append(h, meta);
|
|
969
|
-
const body = document.createElement("div");
|
|
970
|
-
renderMarkdownInto(body, t.section || "No theory section yet.");
|
|
971
|
-
el.theoryBody.append(body);
|
|
972
|
-
const doc = safeHttpUrl(t.docUrl || "");
|
|
973
|
-
if (doc) {
|
|
974
|
-
const p = document.createElement("p");
|
|
975
|
-
const a = document.createElement("a");
|
|
976
|
-
a.href = doc;
|
|
977
|
-
a.target = "_blank";
|
|
978
|
-
a.rel = "noreferrer";
|
|
979
|
-
a.textContent = doc;
|
|
980
|
-
p.append(a);
|
|
981
|
-
el.theoryBody.append(p);
|
|
982
|
-
}
|
|
983
|
-
} catch (e) {
|
|
984
|
-
el.theoryBody.textContent = e.message;
|
|
985
|
-
}
|
|
986
|
-
}
|
|
987
|
-
|
|
988
|
-
async function loadLogs() {
|
|
989
|
-
el.logsBody.textContent = "Loading...";
|
|
990
|
-
try {
|
|
991
|
-
const data = await api("/api/log");
|
|
992
|
-
el.logsBody.textContent = data.text || "No study log yet.";
|
|
993
|
-
} catch (e) {
|
|
994
|
-
el.logsBody.textContent = e.message;
|
|
995
|
-
}
|
|
996
|
-
}
|
|
997
|
-
|
|
998
|
-
function appendStatus(lines) {
|
|
999
|
-
for (const item of lines || []) {
|
|
1000
|
-
const div = document.createElement("div");
|
|
1001
|
-
div.className = "status-line";
|
|
1002
|
-
const t = item?.at ? new Date(item.at).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" }) : "";
|
|
1003
|
-
const strong = document.createElement("strong");
|
|
1004
|
-
strong.textContent = t ? `${t} ` : "";
|
|
1005
|
-
const span = document.createElement("span");
|
|
1006
|
-
span.textContent = item?.line || "";
|
|
1007
|
-
div.append(strong, span);
|
|
1008
|
-
el.statusLog.prepend(div);
|
|
81
|
+
el.certSelect.hidden = true;
|
|
1009
82
|
}
|
|
1010
|
-
while (el.statusLog.children.length > 80) el.statusLog.lastChild.remove();
|
|
1011
|
-
// Newest just under the "Coach log" title; older entries stay below.
|
|
1012
|
-
el.statusLog.parentElement.scrollTop = 0;
|
|
1013
83
|
}
|
|
1014
84
|
|
|
1015
85
|
function connectEvents() {
|
|
1016
|
-
if (eventSource) eventSource.close();
|
|
1017
|
-
eventSource = new EventSource("/api/events");
|
|
1018
|
-
eventSource.onopen = () => setConnection("live");
|
|
1019
|
-
eventSource.onerror = () => setConnection("down");
|
|
1020
|
-
eventSource.addEventListener("state", (event) => {
|
|
86
|
+
if (store.eventSource) store.eventSource.close();
|
|
87
|
+
store.eventSource = new EventSource("/api/events");
|
|
88
|
+
store.eventSource.onopen = () => setConnection("live");
|
|
89
|
+
store.eventSource.onerror = () => setConnection("down");
|
|
90
|
+
store.eventSource.addEventListener("state", (event) => {
|
|
1021
91
|
try {
|
|
1022
92
|
const data = JSON.parse(event.data);
|
|
1023
|
-
if (data.state)
|
|
93
|
+
if (data.state) renderState(data.state);
|
|
1024
94
|
if (data.status?.length) appendStatus(data.status);
|
|
1025
95
|
setConnection("live");
|
|
1026
96
|
} catch {
|
|
1027
97
|
/* ignore malformed event */
|
|
1028
98
|
}
|
|
1029
99
|
});
|
|
1030
|
-
eventSource.addEventListener("chat", (event) => {
|
|
100
|
+
store.eventSource.addEventListener("chat", (event) => {
|
|
1031
101
|
try { handleChatEvent(JSON.parse(event.data)); } catch { /* ignore malformed event */ }
|
|
1032
102
|
});
|
|
1033
103
|
}
|
|
1034
104
|
|
|
1035
|
-
async function loadCertPicker(current) {
|
|
1036
|
-
try {
|
|
1037
|
-
const data = await api("/api/certs");
|
|
1038
|
-
el.certSelect.replaceChildren();
|
|
1039
|
-
for (const c of data.certs || []) {
|
|
1040
|
-
const opt = document.createElement("option");
|
|
1041
|
-
opt.value = c.code;
|
|
1042
|
-
opt.textContent = c.title ? `${c.code} — ${c.title}` : c.code;
|
|
1043
|
-
if (c.code === current) opt.selected = true;
|
|
1044
|
-
el.certSelect.append(opt);
|
|
1045
|
-
}
|
|
1046
|
-
el.certSelect.disabled = (data.certs || []).length < 2;
|
|
1047
|
-
} catch {
|
|
1048
|
-
el.certSelect.hidden = true;
|
|
1049
|
-
}
|
|
1050
|
-
}
|
|
1051
|
-
|
|
1052
105
|
async function bootstrap() {
|
|
1053
106
|
try {
|
|
1054
107
|
// Strip the token from the address bar; the server exchanged it for a cookie.
|
|
@@ -1060,15 +113,12 @@ async function bootstrap() {
|
|
|
1060
113
|
const data = await api("/api/bootstrap");
|
|
1061
114
|
el.certLine.textContent = `${data.cert || "cert"} · ${data.title || ""}`;
|
|
1062
115
|
setLayout(localStorage.getItem("certCoachLayout") || "triptych");
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
el.chatSend.disabled = !chatEnabled;
|
|
1067
|
-
el.chatSend.textContent = chatEnabled ? "Send" : "Chat off";
|
|
1068
|
-
setChatStatus(chatEnabled ? "live" : "off", chatEnabled ? "ready" : "offline");
|
|
116
|
+
store.chat.enabled = Boolean(data.chat?.enabled);
|
|
117
|
+
store.chat.reason = data.chat?.status?.error || "";
|
|
118
|
+
syncChatControls();
|
|
1069
119
|
renderChatHistory(data.chat?.messages || []);
|
|
1070
120
|
await loadCertPicker(data.cert);
|
|
1071
|
-
|
|
121
|
+
renderState(data.state);
|
|
1072
122
|
setView("study");
|
|
1073
123
|
connectEvents();
|
|
1074
124
|
} catch (e) {
|
|
@@ -1078,9 +128,11 @@ async function bootstrap() {
|
|
|
1078
128
|
}
|
|
1079
129
|
}
|
|
1080
130
|
|
|
131
|
+
// ── listeners ────────────────────────────────────────────────────────────────
|
|
1081
132
|
el.submit.addEventListener("click", submitAnswer);
|
|
1082
133
|
el.next.addEventListener("click", nextQuestion);
|
|
1083
134
|
el.teachNext.addEventListener("click", nextQuestion);
|
|
135
|
+
|
|
1084
136
|
el.certSelect.addEventListener("change", async () => {
|
|
1085
137
|
const code = el.certSelect.value;
|
|
1086
138
|
try {
|
|
@@ -1092,6 +144,7 @@ el.certSelect.addEventListener("change", async () => {
|
|
|
1092
144
|
el.certSelect.disabled = false;
|
|
1093
145
|
}
|
|
1094
146
|
});
|
|
147
|
+
|
|
1095
148
|
el.certNew.addEventListener("click", async () => {
|
|
1096
149
|
const raw = window.prompt("New certification code (e.g. AZ-104, DP-600, AWS-SAA):");
|
|
1097
150
|
if (!raw) return;
|
|
@@ -1102,18 +155,27 @@ el.certNew.addEventListener("click", async () => {
|
|
|
1102
155
|
showNotice(e.message);
|
|
1103
156
|
}
|
|
1104
157
|
});
|
|
158
|
+
|
|
1105
159
|
el.start.addEventListener("click", () => startPractice("practice"));
|
|
1106
160
|
el.startProbe.addEventListener("click", () => startPractice("probe"));
|
|
1107
161
|
el.startMock.addEventListener("click", () => startPractice("mock"));
|
|
1108
162
|
el.mockNew.addEventListener("click", () => startPractice("mock"));
|
|
1109
163
|
el.mockPractice.addEventListener("click", () => startPractice("practice"));
|
|
164
|
+
|
|
1110
165
|
for (const nav of [el.nav, el.sideNav]) {
|
|
1111
166
|
nav?.addEventListener("click", (e) => {
|
|
1112
167
|
const b = e.target.closest("button[data-tab]");
|
|
1113
168
|
if (b) setView(b.dataset.tab);
|
|
1114
169
|
});
|
|
1115
170
|
}
|
|
1116
|
-
|
|
171
|
+
|
|
172
|
+
for (const b of el.layoutButtons) {
|
|
173
|
+
b.addEventListener("click", () => {
|
|
174
|
+
setLayout(b.dataset.appLayout);
|
|
175
|
+
if (store.activeLayout === "command") void updateCommandStrip();
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
|
|
1117
179
|
el.chatForm.addEventListener("submit", (e) => { e.preventDefault(); sendChat(); });
|
|
1118
180
|
el.chatStop.addEventListener("click", abortChat);
|
|
1119
181
|
el.chatInput.addEventListener("keydown", (e) => {
|
|
@@ -1122,31 +184,33 @@ el.chatInput.addEventListener("keydown", (e) => {
|
|
|
1122
184
|
sendChat();
|
|
1123
185
|
}
|
|
1124
186
|
});
|
|
187
|
+
|
|
1125
188
|
for (const b of el.confidenceButtons.querySelectorAll("button")) {
|
|
1126
189
|
b.addEventListener("click", () => {
|
|
1127
|
-
confidence = Number(b.dataset.confidence);
|
|
190
|
+
store.confidence = Number(b.dataset.confidence);
|
|
1128
191
|
updateSelectionUI();
|
|
1129
192
|
});
|
|
1130
193
|
}
|
|
1131
194
|
|
|
195
|
+
// Keyboard: letter/number picks an option, Enter submits or advances.
|
|
1132
196
|
document.addEventListener("keydown", (e) => {
|
|
1133
|
-
if (!state) return;
|
|
197
|
+
if (!store.state) return;
|
|
1134
198
|
const tag = (e.target?.tagName || "").toLowerCase();
|
|
1135
199
|
if (["input", "textarea", "select"].includes(tag)) return;
|
|
1136
|
-
if (state.phase === "question" && state.q) {
|
|
200
|
+
if (store.state.phase === "question" && store.state.q) {
|
|
1137
201
|
const key = e.key.toUpperCase();
|
|
1138
202
|
if (/^[A-H]$/.test(key) || /^[1-8]$/.test(key)) {
|
|
1139
203
|
const idx = /^[1-8]$/.test(key) ? Number(key) - 1 : key.charCodeAt(0) - 65;
|
|
1140
|
-
const opt = state.q.options?.[idx];
|
|
204
|
+
const opt = store.state.q.options?.[idx];
|
|
1141
205
|
if (opt) {
|
|
1142
206
|
e.preventDefault();
|
|
1143
207
|
toggleOption(opt.value);
|
|
1144
208
|
}
|
|
1145
|
-
} else if (e.key === "Enter" && selected.size) {
|
|
209
|
+
} else if (e.key === "Enter" && store.selected.size) {
|
|
1146
210
|
e.preventDefault();
|
|
1147
211
|
submitAnswer();
|
|
1148
212
|
}
|
|
1149
|
-
} else if (["feedback", "teach"].includes(state.phase) && e.key === "Enter") {
|
|
213
|
+
} else if (["feedback", "teach"].includes(store.state.phase) && e.key === "Enter") {
|
|
1150
214
|
e.preventDefault();
|
|
1151
215
|
nextQuestion();
|
|
1152
216
|
}
|