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