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:
@@ -0,0 +1,87 @@
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import type { UIMessage } from "ai";
|
||||
import { PATHS } from "../../config.ts";
|
||||
|
||||
/**
|
||||
* Chat store — one JSON file per chat in data/chats/.
|
||||
*
|
||||
* The stored messages are full AI SDK UIMessages (parts included). This is
|
||||
* the canonical session record: a pending quiz is a tool part in
|
||||
* `input-available` state, which is exactly what makes resume-mid-question
|
||||
* free. The extraction pipeline (src/extract) will read these later; old pi
|
||||
* sessions (data/sessions) remain bootstrap input only.
|
||||
*/
|
||||
|
||||
export interface ChatRecord {
|
||||
id: string;
|
||||
title: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
messages: UIMessage[];
|
||||
}
|
||||
|
||||
export interface ChatSummary {
|
||||
id: string;
|
||||
title: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
messageCount: number;
|
||||
}
|
||||
|
||||
function chatFile(id: string): string {
|
||||
if (!/^[A-Za-z0-9_-]+$/.test(id)) throw new Error(`Invalid chat id: ${id}`);
|
||||
return path.join(PATHS.chats, `${id}.json`);
|
||||
}
|
||||
|
||||
export function createChat(title: string): ChatRecord {
|
||||
const now = new Date().toISOString();
|
||||
const record: ChatRecord = {
|
||||
id: `chat_${Date.now().toString(36)}_${crypto.randomUUID().slice(0, 8)}`,
|
||||
title: title.trim() || "Untitled session",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
messages: [],
|
||||
};
|
||||
fs.mkdirSync(PATHS.chats, { recursive: true });
|
||||
fs.writeFileSync(chatFile(record.id), JSON.stringify(record, null, "\t"), "utf-8");
|
||||
return record;
|
||||
}
|
||||
|
||||
export function getChat(id: string): ChatRecord | null {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(chatFile(id), "utf-8")) as ChatRecord;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function listChats(): ChatSummary[] {
|
||||
try {
|
||||
fs.mkdirSync(PATHS.chats, { recursive: true });
|
||||
return fs
|
||||
.readdirSync(PATHS.chats)
|
||||
.filter((f) => f.endsWith(".json"))
|
||||
.map((f) => getChat(f.slice(0, -5)))
|
||||
.filter((r): r is ChatRecord => r !== null)
|
||||
.map(({ messages, ...summary }) => ({ ...summary, messageCount: messages.length }))
|
||||
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** Persist the full message list (client state + new response) after a turn. */
|
||||
export function saveMessages(id: string, messages: UIMessage[]): void {
|
||||
const record = getChat(id);
|
||||
if (!record) throw new Error(`Chat not found: ${id}`);
|
||||
record.messages = messages;
|
||||
record.updatedAt = new Date().toISOString();
|
||||
fs.writeFileSync(chatFile(id), JSON.stringify(record, null, "\t"), "utf-8");
|
||||
}
|
||||
|
||||
/** Derive a short title from the first user message. */
|
||||
export function titleFromPrompt(prompt: string): string {
|
||||
const oneLine = prompt.replace(/\s+/g, " ").trim();
|
||||
return oneLine.length > 72 ? `${oneLine.slice(0, 72)}…` : oneLine;
|
||||
}
|
||||
Reference in New Issue
Block a user