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:
Amos
2026-08-22 00:19:49 +03:00
parent 9a6af9697f
commit 25063b2a3e
32 changed files with 9717 additions and 143 deletions
+142
View File
@@ -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>
);
}