v1 web interface: dispatch-mode quiz/ask, Fastify+WS server, React frontend, deterministic test suite
- extensions/core/question-core.ts: pure quiz/ask logic (normalize/shuffle/resolve/grade/format) shared by both interfaces - extensions/core/bridge.ts: in-process server<->extension bridge (answer/dismiss/re-arm) - extensions/quiz-web.ts + ask-web.ts: web dispatch mode — tool returns immediately, graded answer arrives as next user message; sessions resumable mid-question via lh_question/lh_question_answered custom entries (never in LLM context) - extensions/quiz.ts: refactored onto shared core (terminal blocking mode unchanged) - src/server/index.ts: Fastify + WebSocket; REST (list/create/resume), entry sync (diff-based, all message kinds), streaming deltas, pending-question push - web/: Vite + React — home view (graph placeholder, session list, new-session input), session view (markdown+KaTeX+mermaid transcript, quiz cards, streaming) - src/testing/scripted-model.ts: deterministic fake LLM (scripted model runtime injected via factory overrides) - tests/: 48 deterministic tests (unit: question-core + bridge; integration: full pi loop with scripted model incl. resume re-arm) - scripts/e2e.ts: live smoke test (manual, real LLM, never a gate) - teacher/system-prompt.md: end-your-turn rule for question tools
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>learning harness</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
+154
@@ -0,0 +1,154 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import type { ServerMessage, SessionSummary } from "../../src/shared/protocol.ts";
|
||||
import { Home } from "./views/Home.tsx";
|
||||
import { SessionView } from "./views/SessionView.tsx";
|
||||
|
||||
export interface LiveState {
|
||||
connected: boolean;
|
||||
entries: import("../../src/shared/protocol.ts").ClientEntry[];
|
||||
pending: import("../../src/shared/protocol.ts").ClientQuestion | null;
|
||||
liveText: string;
|
||||
streaming: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export function App() {
|
||||
const [view, setView] = useState<"home" | "session">("home");
|
||||
const [sessions, setSessions] = useState<SessionSummary[]>([]);
|
||||
const [live, setLive] = useState<LiveState>({
|
||||
connected: false,
|
||||
entries: [],
|
||||
pending: null,
|
||||
liveText: "",
|
||||
streaming: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const liveTextRef = useRef("");
|
||||
|
||||
const refreshSessions = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/sessions");
|
||||
const data = (await res.json()) as { sessions: SessionSummary[] };
|
||||
setSessions(data.sessions ?? []);
|
||||
} catch {
|
||||
// server down — keep whatever we have
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void refreshSessions();
|
||||
}, [refreshSessions]);
|
||||
|
||||
// ─── WebSocket lifecycle ──────────────────────────────────────────────
|
||||
const connect = useCallback(() => {
|
||||
const proto = location.protocol === "https:" ? "wss" : "ws";
|
||||
const ws = new WebSocket(`${proto}://${location.host}/ws`);
|
||||
wsRef.current = ws;
|
||||
|
||||
ws.onopen = () => setLive((s) => ({ ...s, connected: true, error: null }));
|
||||
ws.onclose = () => setLive((s) => ({ ...s, connected: false }));
|
||||
ws.onerror = () => setLive((s) => ({ ...s, connected: false, error: "connection error" }));
|
||||
ws.onmessage = (ev) => {
|
||||
const msg = JSON.parse(ev.data as string) as ServerMessage;
|
||||
switch (msg.type) {
|
||||
case "hello":
|
||||
setLive((s) => ({ ...s, entries: [], pending: null, liveText: "", streaming: false }));
|
||||
liveTextRef.current = "";
|
||||
break;
|
||||
case "backfill":
|
||||
setLive((s) => ({ ...s, entries: msg.entries }));
|
||||
break;
|
||||
case "entry":
|
||||
setLive((s) => ({ ...s, entries: [...s.entries, msg.entry] }));
|
||||
if (msg.entry.type === "assistant") {
|
||||
liveTextRef.current = "";
|
||||
setLive((s) => ({ ...s, liveText: "" }));
|
||||
}
|
||||
break;
|
||||
case "pending":
|
||||
setLive((s) => ({ ...s, pending: msg.question }));
|
||||
break;
|
||||
case "delta":
|
||||
liveTextRef.current += msg.delta;
|
||||
setLive((s) => ({ ...s, liveText: liveTextRef.current }));
|
||||
break;
|
||||
case "streaming":
|
||||
setLive((s) => ({ ...s, streaming: msg.value }));
|
||||
break;
|
||||
case "error":
|
||||
setLive((s) => ({ ...s, error: msg.message }));
|
||||
break;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (view === "session") connect();
|
||||
return () => {
|
||||
wsRef.current?.close();
|
||||
wsRef.current = null;
|
||||
};
|
||||
}, [view, connect]);
|
||||
|
||||
const send = useCallback((msg: unknown) => {
|
||||
wsRef.current?.send(JSON.stringify(msg));
|
||||
}, []);
|
||||
|
||||
// ─── actions ──────────────────────────────────────────────────────────
|
||||
const startSession = useCallback(
|
||||
async (topic: string) => {
|
||||
const res = await fetch("/api/sessions", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ topic }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
setLive((s) => ({ ...s, error: `failed to create session (${res.status})` }));
|
||||
return;
|
||||
}
|
||||
setView("session");
|
||||
void refreshSessions();
|
||||
},
|
||||
[refreshSessions],
|
||||
);
|
||||
|
||||
const resumeSession = useCallback(
|
||||
async (file: string) => {
|
||||
const res = await fetch(`/api/sessions/${encodeURIComponent(file)}/resume`, { method: "POST" });
|
||||
if (!res.ok) {
|
||||
setLive((s) => ({ ...s, error: `failed to resume session (${res.status})` }));
|
||||
return;
|
||||
}
|
||||
setView("session");
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const goHome = useCallback(() => {
|
||||
wsRef.current?.close();
|
||||
wsRef.current = null;
|
||||
setView("home");
|
||||
void refreshSessions();
|
||||
}, [refreshSessions]);
|
||||
|
||||
if (view === "home") {
|
||||
return (
|
||||
<Home
|
||||
sessions={sessions}
|
||||
onStart={startSession}
|
||||
onResume={resumeSession}
|
||||
onRefresh={refreshSessions}
|
||||
hasActive={live.connected}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<SessionView
|
||||
live={live}
|
||||
send={send}
|
||||
onHome={goHome}
|
||||
/>
|
||||
);
|
||||
}
|
||||
+588
@@ -0,0 +1,588 @@
|
||||
/* learning harness — minimal v1 dark theme */
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
:root {
|
||||
--bg: #14161a;
|
||||
--bg-panel: #1b1e24;
|
||||
--bg-raised: #22262e;
|
||||
--border: #2e333d;
|
||||
--text: #d8dce4;
|
||||
--text-muted: #8b93a3;
|
||||
--accent: #7aa2f7;
|
||||
--success: #9ece6a;
|
||||
--error: #f7768e;
|
||||
--warning: #e0af68;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
font-size: 15px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
button {
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
background: var(--bg-raised);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 6px 14px;
|
||||
cursor: pointer;
|
||||
}
|
||||
button:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: default;
|
||||
}
|
||||
button.primary {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
color: #10131a;
|
||||
font-weight: 600;
|
||||
}
|
||||
button.ghost {
|
||||
background: transparent;
|
||||
}
|
||||
button.icon-btn {
|
||||
padding: 2px 8px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
font-size: 16px;
|
||||
}
|
||||
button.icon-btn:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
input[type="text"],
|
||||
textarea {
|
||||
font: inherit;
|
||||
color: var(--text);
|
||||
background: var(--bg-raised);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 9px 12px;
|
||||
outline: none;
|
||||
}
|
||||
input[type="text"]:focus,
|
||||
textarea:focus {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
input[type="text"]:disabled,
|
||||
textarea:disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* ─── home ─────────────────────────────────────────────────────── */
|
||||
|
||||
.home {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: 260px;
|
||||
flex-shrink: 0;
|
||||
border-right: 1px solid var(--border);
|
||||
background: var(--bg-panel);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.sidebar-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 14px 14px 10px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.session-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0 8px 8px;
|
||||
}
|
||||
|
||||
.session-item {
|
||||
display: block;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
padding: 9px 10px;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
.session-item:hover {
|
||||
background: var(--bg-raised);
|
||||
}
|
||||
.session-name {
|
||||
color: var(--text);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.session-meta {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.empty {
|
||||
color: var(--text-muted);
|
||||
padding: 12px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.home-main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 24px;
|
||||
gap: 20px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.graph-placeholder {
|
||||
flex: 1;
|
||||
border: 1px dashed var(--border);
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--bg-panel);
|
||||
}
|
||||
|
||||
.graph-placeholder-inner {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.graph-placeholder-title {
|
||||
font-size: 17px;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.graph-placeholder-sub {
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
opacity: 0.7;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.new-session {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.new-session input {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* ─── session view ─────────────────────────────────────────────── */
|
||||
|
||||
.session {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.session-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 18px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--bg-panel);
|
||||
}
|
||||
|
||||
.session-head-status {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
}
|
||||
.dot.on {
|
||||
background: var(--accent);
|
||||
animation: pulse 1.2s infinite;
|
||||
}
|
||||
.dot.ok {
|
||||
background: var(--success);
|
||||
}
|
||||
.dot.q {
|
||||
background: var(--warning);
|
||||
}
|
||||
.dot.off {
|
||||
background: var(--text-muted);
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.35;
|
||||
}
|
||||
}
|
||||
|
||||
.abort-btn {
|
||||
border-color: var(--error);
|
||||
color: var(--error);
|
||||
background: transparent;
|
||||
padding: 4px 12px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.transcript {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 20px 0;
|
||||
}
|
||||
|
||||
.msg {
|
||||
padding: 4px 24px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.msg.user {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.msg.user .msg-bubble {
|
||||
background: var(--bg-raised);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px 12px 4px 12px;
|
||||
padding: 10px 14px;
|
||||
max-width: 72%;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.msg.assistant {
|
||||
max-width: 820px;
|
||||
}
|
||||
|
||||
.tool-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
background: var(--bg-raised);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
padding: 3px 12px;
|
||||
font-size: 12.5px;
|
||||
margin-bottom: 10px;
|
||||
margin-right: 8px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.tool-name {
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
.tool-arg {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 420px;
|
||||
}
|
||||
|
||||
.tool-result {
|
||||
padding: 2px 24px;
|
||||
font-size: 12.5px;
|
||||
color: var(--text-muted);
|
||||
font-style: italic;
|
||||
max-width: 820px;
|
||||
}
|
||||
|
||||
.composer {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
padding: 14px 18px;
|
||||
border-top: 1px solid var(--border);
|
||||
background: var(--bg-panel);
|
||||
}
|
||||
|
||||
.composer input {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.error-banner {
|
||||
margin: 10px 24px;
|
||||
padding: 9px 14px;
|
||||
border: 1px solid var(--error);
|
||||
border-radius: 6px;
|
||||
color: var(--error);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* ─── markdown ─────────────────────────────────────────────────── */
|
||||
|
||||
.markdown p {
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
.markdown h1,
|
||||
.markdown h2,
|
||||
.markdown h3 {
|
||||
margin: 20px 0 10px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
.markdown h1 {
|
||||
font-size: 22px;
|
||||
}
|
||||
.markdown h2 {
|
||||
font-size: 19px;
|
||||
}
|
||||
.markdown h3 {
|
||||
font-size: 16.5px;
|
||||
}
|
||||
.markdown ul,
|
||||
.markdown ol {
|
||||
margin: 0 0 12px;
|
||||
padding-left: 26px;
|
||||
}
|
||||
.markdown li {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.markdown code {
|
||||
background: var(--bg-raised);
|
||||
border-radius: 4px;
|
||||
padding: 1px 5px;
|
||||
font-size: 13px;
|
||||
font-family: ui-monospace, "SF Mono", Menlo, monospace;
|
||||
}
|
||||
.markdown pre {
|
||||
background: var(--bg-raised);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 12px 14px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
.markdown pre code {
|
||||
background: none;
|
||||
padding: 0;
|
||||
}
|
||||
.markdown blockquote {
|
||||
border-left: 3px solid var(--border);
|
||||
margin: 0 0 12px;
|
||||
padding: 2px 14px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.markdown table {
|
||||
border-collapse: collapse;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.markdown th,
|
||||
.markdown td {
|
||||
border: 1px solid var(--border);
|
||||
padding: 6px 12px;
|
||||
}
|
||||
.markdown .katex-display {
|
||||
overflow-x: auto;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.mermaid {
|
||||
background: var(--bg-panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
margin-bottom: 12px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
overflow-x: auto;
|
||||
}
|
||||
.mermaid svg {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
/* ─── quiz cards ───────────────────────────────────────────────── */
|
||||
|
||||
.quiz-card {
|
||||
max-width: 680px;
|
||||
margin: 10px 24px 18px;
|
||||
background: var(--bg-panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
padding: 16px 18px;
|
||||
}
|
||||
.quiz-card.quiz {
|
||||
border-color: #3b4252;
|
||||
}
|
||||
.quiz-card.answered {
|
||||
opacity: 0.92;
|
||||
}
|
||||
|
||||
.quiz-kind {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: var(--accent);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.quiz-card.ask .quiz-kind {
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
.quiz-question {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.quiz-context {
|
||||
color: var(--text-muted);
|
||||
font-size: 13.5px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.quiz-options {
|
||||
margin-top: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.quiz-option {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
background: var(--bg-raised);
|
||||
}
|
||||
.quiz-option:hover {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.quiz-option.selected {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.quiz-option.static {
|
||||
cursor: default;
|
||||
background: transparent;
|
||||
}
|
||||
.quiz-option.static.correct {
|
||||
border-color: var(--success);
|
||||
}
|
||||
.quiz-option.static.wrong {
|
||||
border-color: var(--error);
|
||||
}
|
||||
|
||||
.quiz-option input {
|
||||
margin-top: 3px;
|
||||
accent-color: var(--accent);
|
||||
}
|
||||
|
||||
.quiz-option-label {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.quiz-option-desc {
|
||||
display: block;
|
||||
color: var(--text-muted);
|
||||
font-size: 12.5px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.quiz-mark {
|
||||
width: 18px;
|
||||
flex-shrink: 0;
|
||||
text-align: center;
|
||||
font-weight: 700;
|
||||
}
|
||||
.quiz-option.static.correct .quiz-mark {
|
||||
color: var(--success);
|
||||
}
|
||||
.quiz-option.static.wrong .quiz-mark {
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
.quiz-option.dont-know {
|
||||
border-style: dashed;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.quiz-option.dont-know.selected {
|
||||
color: var(--warning);
|
||||
border-color: var(--warning);
|
||||
}
|
||||
|
||||
.quiz-other,
|
||||
.quiz-text {
|
||||
margin-top: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.quiz-note {
|
||||
margin-top: 10px;
|
||||
width: 100%;
|
||||
resize: vertical;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.quiz-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.quiz-verdict {
|
||||
margin-top: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.quiz-verdict.correct {
|
||||
color: var(--success);
|
||||
}
|
||||
.quiz-verdict.wrong {
|
||||
color: var(--error);
|
||||
}
|
||||
.quiz-verdict.dont-know {
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
.quiz-note-display {
|
||||
margin-top: 8px;
|
||||
color: var(--text-muted);
|
||||
font-size: 13.5px;
|
||||
}
|
||||
|
||||
.quiz-explanation {
|
||||
margin-top: 10px;
|
||||
padding-top: 10px;
|
||||
border-top: 1px solid var(--border);
|
||||
color: var(--text);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.quiz-text-answer {
|
||||
margin-top: 10px;
|
||||
padding: 8px 12px;
|
||||
background: var(--bg-raised);
|
||||
border-radius: 6px;
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import type { QuestionAnswerData } from "../../../src/shared/protocol.ts";
|
||||
|
||||
/**
|
||||
* AnsweredCard — static rendering of a question that has been answered.
|
||||
* All the grading data (correct indices, explanation) is in the answer record
|
||||
* (it lived in a custom entry, which never entered LLM context).
|
||||
*/
|
||||
export function AnsweredCard({ data }: { data: QuestionAnswerData }) {
|
||||
if (data.dismissed) {
|
||||
return (
|
||||
<div className="quiz-card answered dismissed">
|
||||
<div className="quiz-kind">{data.kind === "quiz" ? "Quiz" : "Question"}</div>
|
||||
<div className="quiz-question">{data.questionText}</div>
|
||||
<div className="quiz-verdict dont-know">Skipped</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const selectedSet = new Set(data.answers.map((a) => a.index));
|
||||
const correctSet = new Set(data.correctIndices ?? []);
|
||||
|
||||
return (
|
||||
<div className={`quiz-card answered ${data.kind}`}>
|
||||
<div className="quiz-kind">{data.kind === "quiz" ? "Quiz" : "Question"}</div>
|
||||
<div className="quiz-question">{data.questionText}</div>
|
||||
|
||||
{data.options && data.options.length > 0 && (
|
||||
<div className="quiz-options">
|
||||
{data.options.map((opt) => {
|
||||
const isSelected = selectedSet.has(opt.index);
|
||||
const isKey = correctSet.has(opt.index);
|
||||
let cls = "";
|
||||
if (data.dontKnow) cls = isKey ? "correct" : "";
|
||||
else if (isSelected && isKey) cls = "correct selected";
|
||||
else if (isSelected) cls = "wrong";
|
||||
else if (isKey) cls = "correct";
|
||||
const mark = isSelected ? (isKey || data.dontKnow ? "✓" : "✗") : isKey ? "✓" : "";
|
||||
return (
|
||||
<div key={opt.index} className={`quiz-option static ${cls}`}>
|
||||
<span className="quiz-mark">{mark}</span>
|
||||
<span className="quiz-option-label">
|
||||
{opt.index}. {opt.label}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{data.other && (
|
||||
<div className="quiz-option static">
|
||||
<span className="quiz-mark">✓</span>
|
||||
<span className="quiz-option-label">Other: {data.other}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(data.text || (!data.options?.length && !data.answers.length)) && data.text && (
|
||||
<div className="quiz-text-answer">{data.text}</div>
|
||||
)}
|
||||
{data.other && !data.options?.length && <div className="quiz-text-answer">Other: {data.other}</div>}
|
||||
|
||||
{data.kind === "quiz" && (
|
||||
<div className={`quiz-verdict ${data.dontKnow ? "dont-know" : data.correct ? "correct" : "wrong"}`}>
|
||||
{data.dontKnow ? "I don't know" : data.correct ? "Correct" : "Incorrect"}
|
||||
</div>
|
||||
)}
|
||||
{data.note && <div className="quiz-note-display">Note: {data.note}</div>}
|
||||
{data.kind === "quiz" && data.explanation && (
|
||||
<div className="quiz-explanation">{data.explanation}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* GraphPlaceholder — the home view's main panel. Deliberately isolated: the
|
||||
* real knowledge graph (territory map, domain clusters, frontier) is a
|
||||
* separate research/planning phase and will replace ONLY this component.
|
||||
*/
|
||||
export function GraphPlaceholder() {
|
||||
return (
|
||||
<div className="graph-placeholder">
|
||||
<div className="graph-placeholder-inner">
|
||||
<div className="graph-placeholder-title">Knowledge graph</div>
|
||||
<div className="graph-placeholder-sub">
|
||||
Coming in phase 2 — the territory map, repairs, and unlock views.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { memo, useEffect, useRef, useState } from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkMath from "remark-math";
|
||||
import rehypeKatex from "rehype-katex";
|
||||
import "katex/dist/katex.min.css";
|
||||
|
||||
/**
|
||||
* Markdown — renders assistant prose with LaTeX (KaTeX) and mermaid diagrams
|
||||
* (the teacher's Phase-2 dependency maps are mermaid code blocks).
|
||||
*/
|
||||
|
||||
let mermaidPromise: Promise<typeof import("mermaid")> | null = null;
|
||||
function getMermaid() {
|
||||
if (!mermaidPromise) {
|
||||
mermaidPromise = import("mermaid").then((m) => {
|
||||
m.default.initialize({ startOnLoad: false, theme: "dark" });
|
||||
return m;
|
||||
});
|
||||
}
|
||||
return mermaidPromise;
|
||||
}
|
||||
|
||||
function MermaidBlock({ code }: { code: string }) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
try {
|
||||
const mermaid = await getMermaid();
|
||||
const { svg } = await mermaid.default.render(`mmd-${Math.random().toString(36).slice(2)}`, code);
|
||||
if (!cancelled && ref.current) ref.current.innerHTML = svg;
|
||||
} catch (e) {
|
||||
if (!cancelled) setError(String(e));
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [code]);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<pre className="mermaid-error">
|
||||
<code>{code}</code>
|
||||
</pre>
|
||||
);
|
||||
}
|
||||
return <div className="mermaid" ref={ref} />;
|
||||
}
|
||||
|
||||
export const Markdown = memo(function Markdown({ text }: { text: string }) {
|
||||
return (
|
||||
<div className="markdown">
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkMath]}
|
||||
rehypePlugins={[rehypeKatex]}
|
||||
components={{
|
||||
code({ className, children, ...props }) {
|
||||
const lang = /language-(\w+)/.exec(className ?? "")?.[1];
|
||||
const raw = String(children).replace(/\n$/, "");
|
||||
if (lang === "mermaid") return <MermaidBlock code={raw} />;
|
||||
return (
|
||||
<code className={className} {...props}>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
},
|
||||
pre({ children }) {
|
||||
const child = children as { props?: { className?: string } };
|
||||
if (child?.props?.className?.includes("language-mermaid")) return <>{children}</>;
|
||||
return <pre>{children}</pre>;
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,155 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import type { ClientQuestion } from "../../../src/shared/protocol.ts";
|
||||
|
||||
interface QuizCardProps {
|
||||
question: ClientQuestion;
|
||||
send: (msg: unknown) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* QuizCard — the interactive answer UI for a pending quiz/ask question.
|
||||
* Mirrors the terminal quiz semantics: single/multi-select, automatic
|
||||
* "I don't know" (quiz only), "Other" free text (ask only), optional note.
|
||||
* Grading happens server-side; the graded card arrives afterwards as a
|
||||
* questionAnswered entry.
|
||||
*/
|
||||
export function QuizCard({ question, send }: QuizCardProps) {
|
||||
const multi = question.mode === "multi-select";
|
||||
const isQuiz = question.kind === "quiz";
|
||||
const isText = question.mode === "text";
|
||||
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
const [dontKnow, setDontKnow] = useState(false);
|
||||
const [other, setOther] = useState("");
|
||||
const [showOther, setShowOther] = useState(false);
|
||||
const [text, setText] = useState("");
|
||||
const [note, setNote] = useState("");
|
||||
|
||||
const canSubmit = useMemo(() => {
|
||||
if (dontKnow) return true;
|
||||
if (isText) return text.trim().length > 0;
|
||||
if (showOther && other.trim()) return true;
|
||||
return selected.size > 0;
|
||||
}, [dontKnow, isText, text, showOther, other, selected]);
|
||||
|
||||
const toggle = (value: string) => {
|
||||
setDontKnow(false);
|
||||
setSelected((prev) => {
|
||||
const next = new Set(multi ? prev : undefined);
|
||||
if (multi) {
|
||||
if (next.has(value)) next.delete(value);
|
||||
else next.add(value);
|
||||
} else {
|
||||
next.clear();
|
||||
next.add(value);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const submit = () => {
|
||||
if (!canSubmit) return;
|
||||
send({
|
||||
type: "answer",
|
||||
questionId: question.id,
|
||||
dontKnow,
|
||||
values: [...selected],
|
||||
other: showOther && other.trim() ? other.trim() : undefined,
|
||||
text: isText ? text.trim() : undefined,
|
||||
note: note.trim() || undefined,
|
||||
});
|
||||
};
|
||||
|
||||
const dismiss = () => {
|
||||
send({ type: "dismiss", questionId: question.id });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`quiz-card ${isQuiz ? "quiz" : "ask"}`}>
|
||||
<div className="quiz-kind">{isQuiz ? "Quiz" : "Question"}</div>
|
||||
<div className="quiz-question">{question.question}</div>
|
||||
{question.context && <div className="quiz-context">{question.context}</div>}
|
||||
|
||||
{isText ? (
|
||||
<textarea
|
||||
className="quiz-text"
|
||||
placeholder="Type your answer…"
|
||||
value={text}
|
||||
rows={3}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
/>
|
||||
) : (
|
||||
<div className="quiz-options">
|
||||
{question.options.map((opt) => (
|
||||
<label key={opt.value} className={`quiz-option ${selected.has(opt.value) ? "selected" : ""}`}>
|
||||
<input
|
||||
type={multi ? "checkbox" : "radio"}
|
||||
name={question.id}
|
||||
checked={selected.has(opt.value)}
|
||||
onChange={() => toggle(opt.value)}
|
||||
/>
|
||||
<span className="quiz-option-label">
|
||||
{opt.label}
|
||||
{opt.description && <span className="quiz-option-desc">{opt.description}</span>}
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
{!isQuiz && (
|
||||
<label className={`quiz-option ${showOther ? "selected" : ""}`}>
|
||||
<input
|
||||
type={multi ? "checkbox" : "radio"}
|
||||
name={question.id}
|
||||
checked={showOther}
|
||||
onChange={() => {
|
||||
setDontKnow(false);
|
||||
setShowOther((v) => !v);
|
||||
}}
|
||||
/>
|
||||
<span className="quiz-option-label">Other</span>
|
||||
</label>
|
||||
)}
|
||||
{showOther && (
|
||||
<input
|
||||
type="text"
|
||||
className="quiz-other"
|
||||
placeholder="Your custom answer…"
|
||||
value={other}
|
||||
onChange={(e) => setOther(e.target.value)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isQuiz && (
|
||||
<label className={`quiz-option dont-know ${dontKnow ? "selected" : ""}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={dontKnow}
|
||||
onChange={() => {
|
||||
setDontKnow((v) => !v);
|
||||
if (!dontKnow) setSelected(new Set());
|
||||
}}
|
||||
/>
|
||||
<span className="quiz-option-label">I don't know</span>
|
||||
</label>
|
||||
)}
|
||||
|
||||
<textarea
|
||||
className="quiz-note"
|
||||
placeholder="Note (optional) — what you were thinking or unsure about"
|
||||
value={note}
|
||||
rows={2}
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
/>
|
||||
|
||||
<div className="quiz-actions">
|
||||
<button className="primary" onClick={submit} disabled={!canSubmit}>
|
||||
{dontKnow ? "I don't know" : "Submit"}
|
||||
</button>
|
||||
<button className="ghost" onClick={dismiss}>
|
||||
Skip
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { App } from "./App.tsx";
|
||||
import "./app.css";
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
);
|
||||
@@ -0,0 +1,84 @@
|
||||
import { useState } from "react";
|
||||
import type { SessionSummary } from "../../../src/shared/protocol.ts";
|
||||
import { GraphPlaceholder } from "../components/GraphPlaceholder.tsx";
|
||||
|
||||
interface HomeProps {
|
||||
sessions: SessionSummary[];
|
||||
onStart: (topic: string) => void;
|
||||
onResume: (file: string) => void;
|
||||
onRefresh: () => void;
|
||||
hasActive: boolean;
|
||||
}
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
if (!iso) return "";
|
||||
try {
|
||||
return new Date(iso).toLocaleString(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
|
||||
export function Home({ sessions, onStart, onResume, onRefresh }: HomeProps) {
|
||||
const [topic, setTopic] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const submit = async () => {
|
||||
const t = topic.trim();
|
||||
if (!t || busy) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
await onStart(t);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
setTopic("");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="home">
|
||||
<aside className="sidebar">
|
||||
<div className="sidebar-head">
|
||||
<span>Sessions</span>
|
||||
<button className="icon-btn" title="Refresh" onClick={onRefresh}>
|
||||
↻
|
||||
</button>
|
||||
</div>
|
||||
<div className="session-list">
|
||||
{sessions.length === 0 && <div className="empty">No sessions yet</div>}
|
||||
{sessions.map((s) => (
|
||||
<button key={s.id} className="session-item" onClick={() => onResume(s.file)}>
|
||||
<div className="session-name">{s.name || s.preview || "Untitled"}</div>
|
||||
<div className="session-meta">
|
||||
{formatDate(s.updatedAt)} · {s.messageCount} msg{s.messageCount === 1 ? "" : "s"}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</aside>
|
||||
<main className="home-main">
|
||||
<GraphPlaceholder />
|
||||
<div className="new-session">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="What do you want to learn?"
|
||||
value={topic}
|
||||
disabled={busy}
|
||||
onChange={(e) => setTopic(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") void submit();
|
||||
}}
|
||||
/>
|
||||
<button onClick={() => void submit()} disabled={busy || !topic.trim()}>
|
||||
{busy ? "…" : "Start"}
|
||||
</button>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { ClientEntry } from "../../../src/shared/protocol.ts";
|
||||
import type { LiveState } from "../App.tsx";
|
||||
import { Markdown } from "../components/Markdown.tsx";
|
||||
import { QuizCard } from "../components/QuizCard.tsx";
|
||||
import { AnsweredCard } from "../components/AnsweredCard.tsx";
|
||||
|
||||
interface SessionViewProps {
|
||||
live: LiveState;
|
||||
send: (msg: unknown) => void;
|
||||
onHome: () => void;
|
||||
}
|
||||
|
||||
/** User messages that are already rendered as answer cards — don't duplicate. */
|
||||
const HIDDEN_USER_PREFIXES = ["[Quiz answer]", "[Answer to your question]"];
|
||||
|
||||
function EntryBlock({ entry }: { entry: ClientEntry }) {
|
||||
switch (entry.type) {
|
||||
case "user":
|
||||
if (HIDDEN_USER_PREFIXES.some((p) => entry.text.startsWith(p))) return null;
|
||||
return (
|
||||
<div className="msg user">
|
||||
<div className="msg-bubble">{entry.text}</div>
|
||||
</div>
|
||||
);
|
||||
case "assistant":
|
||||
return (
|
||||
<div className="msg assistant">
|
||||
{entry.toolCalls.map((tc, i) => (
|
||||
<div key={i} className="tool-chip">
|
||||
<span className="tool-name">{tc.name}</span>
|
||||
{typeof tc.args.question === "string" && (
|
||||
<span className="tool-arg"> {tc.args.question}</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{entry.text && <Markdown text={entry.text} />}
|
||||
</div>
|
||||
);
|
||||
case "toolResult":
|
||||
return <div className="tool-result">{entry.text}</div>;
|
||||
case "question":
|
||||
// Rendered by the pending-question flow below (cards need answer state);
|
||||
// when answered, the questionAnswered entry renders instead.
|
||||
return null;
|
||||
case "questionAnswered":
|
||||
return <AnsweredCard data={entry.data} />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function SessionView({ live, send, onHome }: SessionViewProps) {
|
||||
const [input, setInput] = useState("");
|
||||
const bottomRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
}, [live.entries.length, live.liveText, live.pending?.id]);
|
||||
|
||||
const submit = () => {
|
||||
const text = input.trim();
|
||||
if (!text || live.streaming || live.pending !== null) return;
|
||||
send({ type: "prompt", text });
|
||||
setInput("");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="session">
|
||||
<header className="session-head">
|
||||
<button className="icon-btn" onClick={onHome} title="Back">
|
||||
←
|
||||
</button>
|
||||
<div className="session-head-status">
|
||||
{!live.connected ? (
|
||||
<>
|
||||
<span className="dot off" /> connecting…
|
||||
</>
|
||||
) : live.streaming ? (
|
||||
<>
|
||||
<span className="dot on" /> teaching…
|
||||
</>
|
||||
) : live.pending !== null ? (
|
||||
<>
|
||||
<span className="dot q" /> waiting for your answer
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="dot ok" /> ready
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{live.streaming && (
|
||||
<button className="abort-btn" onClick={() => send({ type: "abort" })}>
|
||||
Stop
|
||||
</button>
|
||||
)}
|
||||
</header>
|
||||
|
||||
<div className="transcript">
|
||||
{live.entries.map((entry) => (
|
||||
<EntryBlock key={entry.id} entry={entry} />
|
||||
))}
|
||||
{live.liveText && (
|
||||
<div className="msg assistant">
|
||||
<Markdown text={live.liveText} />
|
||||
</div>
|
||||
)}
|
||||
{live.pending && (
|
||||
<QuizCard
|
||||
question={live.pending}
|
||||
send={send}
|
||||
/>
|
||||
)}
|
||||
{live.error && <div className="error-banner">{live.error}</div>}
|
||||
<div ref={bottomRef} />
|
||||
</div>
|
||||
|
||||
<div className="composer">
|
||||
<input
|
||||
type="text"
|
||||
placeholder={
|
||||
live.pending !== null
|
||||
? "Answer the question above first…"
|
||||
: live.streaming
|
||||
? "Teaching…"
|
||||
: "Reply to the teacher…"
|
||||
}
|
||||
value={input}
|
||||
disabled={live.streaming || live.pending !== null}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") submit();
|
||||
}}
|
||||
/>
|
||||
<button onClick={submit} disabled={live.streaming || live.pending !== null || !input.trim()}>
|
||||
Send
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"allowImportingTsExtensions": true,
|
||||
"noEmit": true,
|
||||
"skipLibCheck": true,
|
||||
"types": ["vite/client"]
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx", "../src/shared/protocol.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user