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
+24
View File
@@ -0,0 +1,24 @@
import { isToolUIPart, type UIMessage } from "ai";
/**
* The pending-question gate: an unanswered interaction tool part (quiz/ask)
* blocks the composer — one question at a time, mirroring the teacher's
* prompt contract. Derived client state; the server enforces nothing.
*/
export interface PendingToolCall {
toolCallId: string;
tool: string;
}
export function findPendingToolCall(messages: UIMessage[]): PendingToolCall | null {
for (let m = messages.length - 1; m >= 0; m--) {
const msg = messages[m]!;
for (let p = msg.parts.length - 1; p >= 0; p--) {
const part = msg.parts[p]!;
if (isToolUIPart(part) && part.state === "input-available") {
return { toolCallId: part.toolCallId, tool: part.type.slice("tool-".length) };
}
}
}
return null;
}