v3: AI SDK pivot — pi removed, Next.js 16 + AI SDK 7 + AI Elements teacher app

The teaching agent no longer runs on pi. Stack:
- ToolLoopAgent (ai@7) + OpenRouter (OPENROUTER_API_KEY), model via LH_MODEL,
  default anthropic/claude-sonnet-4.5 with reasoning tokens
- quiz/ask_user_question = client-side tools (no server execute): the call
  lands as a typed tool part in input-available state; the card grades via
  src/core/question-core.ts; addToolOutput sends the verdict; the teacher's
  reaction continues the same assistant message
- resume-mid-quiz is free: the pending tool part persists in the transcript
- persistence: one JSON per chat (data/chats/), full UIMessage transcripts,
  saved onFinish (also on abort)
- UI: AI Elements (owned copy-paste components, pruned to the five in use)
  + button-based QuizCard/AnsweredCard (ask answers never graded)
- pi fully removed: TUI, extensions, Fastify+WS server, bridge, custom
  protocol all deleted (recoverable from git history)
- tests: question-core units carry over; chat-flow integration drives
  handleChatRequest with MockLanguageModelV4 (48 tests, zero tokens);
  render smoke tests guard the cards (renderToStaticMarkup)

Carried over unchanged: teacher system prompt, question-core, schema/
ledger/derive/extract pipeline, data model.
This commit is contained in:
Amos
2026-08-22 02:18:27 +03:00
parent e127a9bfbd
commit a72c902d30
81 changed files with 9643 additions and 10860 deletions
+5
View File
@@ -0,0 +1,5 @@
import { handleChatRequest } from "@/src/server/chat-handler";
export async function POST(req: Request) {
return handleChatRequest(req);
}
+8
View File
@@ -0,0 +1,8 @@
import { getChat } from "@/src/store/chats";
export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const chat = getChat(id);
if (!chat) return Response.json({ error: "Not found" }, { status: 404 });
return Response.json(chat);
}
+12
View File
@@ -0,0 +1,12 @@
import { createChat, listChats, titleFromPrompt } from "@/src/store/chats";
export async function GET() {
return Response.json({ chats: listChats() });
}
export async function POST(req: Request) {
const body = (await req.json().catch(() => ({}))) as { title?: unknown };
const title = typeof body.title === "string" ? body.title : "";
const chat = createChat(titleFromPrompt(title || "Untitled session"));
return Response.json({ id: chat.id, title: chat.title });
}
+26
View File
@@ -0,0 +1,26 @@
import { notFound } from "next/navigation";
import { Chat } from "@/components/chat";
import { getChat } from "@/src/store/chats";
import type { TeacherUIMessage } from "@/src/agent/ui-message";
export const dynamic = "force-dynamic";
export default async function ChatPage({
params,
searchParams,
}: {
params: Promise<{ id: string }>;
searchParams: Promise<{ prompt?: string }>;
}) {
const [{ id }, { prompt }] = await Promise.all([params, searchParams]);
const chat = getChat(id);
if (!chat) notFound();
return (
<Chat
chatId={chat.id}
title={chat.title}
initialMessages={chat.messages as TeacherUIMessage[]}
initialPrompt={prompt}
/>
);
}
+86
View File
@@ -0,0 +1,86 @@
@import "tailwindcss";
@custom-variant dark (&:is(.dark *));
/*
* Design tokens — the single place to restyle the app.
* (The owner has a specific visual design planned; it lands here and in
* component edits, not in structural changes.)
*/
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
}
@theme inline {
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
}
* {
border-color: var(--color-border);
}
body {
background: var(--color-background);
color: var(--color-foreground);
}
+15
View File
@@ -0,0 +1,15 @@
import type { Metadata } from "next";
import "./globals.css";
export const metadata: Metadata = {
title: "Learning Harness",
description: "A teacher agent with a memory — sessions, quizzes, and a knowledge graph of what you know.",
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" className="dark">
<body className="min-h-dvh antialiased">{children}</body>
</html>
);
}
+8
View File
@@ -0,0 +1,8 @@
import { listChats } from "@/src/store/chats";
import { Home } from "@/components/home";
export const dynamic = "force-dynamic";
export default function HomePage() {
return <Home chats={listChats()} />;
}