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,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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user