A new mode where the user explains a topic in their own words and the AI plays a curious classmate: short reactions, exactly one follow-up question at a time, never "correct"/"wrong", never lecturing, and everything it knows comes from the user's own decks and notes. - Entry points: a sidebar row next to Exam mode and a "Teach it back" action in every deck's menu (which prefills the topic and scope). - Start screen: deck or typed topic, friendly/skeptical student, the same context and model chips as Chat (/teach?deck=<id> pre-selects the deck). - Session: the student prompt is a mode of the existing /api/chat route, so model resolution, allowlists, rate limits and the streaming client are the same code; the personality lives in lib/prompts.ts (TEACH_*). The deck's cards travel as a numbered checklist and the student is told to ask about the ideas that were skipped; it must say so rather than guess when the notes cover nothing. One constant (TEACH_MAX_EXCHANGES = 10) ends the session, or the Finish button does. - Recap: a teach_summary call (JSON, one repair retry) yields well / gaps with card references / next steps; each gap links to the card it means. "Add missed cards to review" schedules those activities due now; "Turn gaps into cards" generates one flashcard per gap from the deck's notes and shows an editable review list before saving. - Sessions are chat conversations with a mode:"teach" marker + session record: same IndexedDB history, backup and sync, filtered out of the Chat screens; the recent list reopens a recap or continues a session. - Reused pieces: MessageRow, Composer (now optional attach/scope chips and a mic), Markdown/KaTeX, AiDisclaimer, ContextPicker/ModelPicker, plus a shared useChatModels hook and useKeyboardInset extracted from Chat. - Dictation: Web Speech API recognition (lib/speech-input.ts); the mic only renders where the browser supports it, and dictated phrases land in the draft. - All strings in en + cs. teach-test: 44 checks (API prompts and guards, both entry points, the session, recap actions, history, exchange limit, 360/390px layouts, mic on/off). Version 0.8.1, SW v103.
123 lines
4.6 KiB
TypeScript
123 lines
4.6 KiB
TypeScript
"use client";
|
|
|
|
import { ApiError, consumeNdjson } from "./client-api";
|
|
import { localeHeader, t } from "./i18n";
|
|
import { authHeaders } from "./settings";
|
|
import { providerHeaders, providersStore } from "./providers-client";
|
|
import type { ChatSource } from "./chat";
|
|
|
|
/**
|
|
* Client for the streaming chat route. The conversation window and the chosen
|
|
* context chunks travel with every request (the server never stores either);
|
|
* the API key only ever goes to our own route in its usual header.
|
|
*/
|
|
|
|
export type ChatStreamEvent =
|
|
| { type: "token"; text: string }
|
|
| { type: "thinking"; text: string }
|
|
| { type: "done"; model: string }
|
|
| { type: "error"; error: string; kind?: string };
|
|
|
|
export type StreamChatInput = {
|
|
model?: string;
|
|
/** A cloud provider the model belongs to (Ollama when omitted). */
|
|
provider?: string;
|
|
messages: { role: "user" | "assistant"; content: string }[];
|
|
context: (ChatSource & { text: string })[];
|
|
/** "teach" runs a Teach it back turn; "teach_summary" asks for the recap JSON. */
|
|
mode?: "teach" | "teach_summary";
|
|
teach?: {
|
|
topic: string;
|
|
difficulty: "friendly" | "skeptical";
|
|
/** Exchanges answered so far, and how many the session plans. */
|
|
exchange: number;
|
|
limit: number;
|
|
/** "[n] question → answer" checklist lines (lib/teach.ts). */
|
|
cards: string[];
|
|
};
|
|
signal?: AbortSignal;
|
|
onToken: (text: string) => void;
|
|
onThinking: (text: string) => void;
|
|
onDone: (model: string) => void;
|
|
};
|
|
|
|
export async function streamChat(input: StreamChatInput): Promise<void> {
|
|
let response: Response;
|
|
try {
|
|
response = await fetch("/api/chat", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json", ...authHeaders(), ...providerHeaders(input.provider), ...localeHeader() },
|
|
body: JSON.stringify({
|
|
model: input.model || undefined,
|
|
messages: input.messages,
|
|
context: input.context,
|
|
mode: input.mode,
|
|
teach: input.teach,
|
|
}),
|
|
signal: input.signal,
|
|
});
|
|
} catch (error) {
|
|
if (error instanceof Error && error.name === "AbortError") {
|
|
throw new ApiError(t("api.cancelled"), "aborted");
|
|
}
|
|
throw new ApiError(t("api.unreachable"), "network");
|
|
}
|
|
|
|
if (!response.ok) {
|
|
const body = (await response.json().catch(() => null)) as { error?: string; kind?: string } | null;
|
|
throw new ApiError(body?.error ?? t("api.httpError", { status: response.status }), body?.kind);
|
|
}
|
|
|
|
let streamError: ApiError | null = null;
|
|
try {
|
|
await consumeNdjson(response, (raw) => {
|
|
const event = raw as ChatStreamEvent;
|
|
if (event.type === "token") input.onToken(event.text);
|
|
else if (event.type === "thinking") input.onThinking(event.text);
|
|
else if (event.type === "done") input.onDone(event.model);
|
|
else if (event.type === "error") streamError = new ApiError(event.error, event.kind);
|
|
});
|
|
} catch (error) {
|
|
// Aborting while the body is being read rejects the reader with an
|
|
// AbortError, not a fetch rejection — treat it as a user stop.
|
|
if (error instanceof Error && error.name === "AbortError") {
|
|
throw new ApiError(t("api.cancelled"), "aborted");
|
|
}
|
|
throw error;
|
|
}
|
|
|
|
if (streamError) throw streamError;
|
|
}
|
|
|
|
/**
|
|
* Turns attached photos (and any client-extracted document text) into plain
|
|
* text through the existing vision pipeline, so the chat model itself never
|
|
* needs to be multimodal.
|
|
*/
|
|
export async function extractChatAttachment(
|
|
input: { images?: string[]; text?: string },
|
|
options: { signal?: AbortSignal } = {},
|
|
): Promise<string> {
|
|
// Attachment transcription follows the study-generation provider, if set.
|
|
const generation = providersStore.getSnapshot().generation;
|
|
let response: Response;
|
|
try {
|
|
response = await fetch("/api/chat/extract", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json", ...authHeaders(), ...providerHeaders(generation?.provider), ...localeHeader() },
|
|
body: JSON.stringify(generation ? { ...input, model: generation.visionModel || generation.textModel } : input),
|
|
signal: options.signal,
|
|
});
|
|
} catch (error) {
|
|
if (error instanceof Error && error.name === "AbortError") {
|
|
throw new ApiError(t("api.cancelled"), "aborted");
|
|
}
|
|
throw new ApiError(t("api.unreachable"), "network");
|
|
}
|
|
const body = (await response.json().catch(() => null)) as { text?: string; error?: string; kind?: string } | null;
|
|
if (!response.ok) {
|
|
throw new ApiError(body?.error ?? t("api.httpError", { status: response.status }), body?.kind);
|
|
}
|
|
if (!body?.text?.trim()) throw new ApiError(t("chat.attachEmpty"), "bad_response");
|
|
return body.text.trim();
|
|
}
|