Teach it back: the AI becomes the curious student
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.
This commit is contained in:
parent
3fb491c54d
commit
6036238bf0
25 changed files with 2821 additions and 181 deletions
103
lib/speech-input.ts
Normal file
103
lib/speech-input.ts
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* Browser dictation for "teach it back" (and anything else that wants it):
|
||||
* the Web Speech API's recognition side. Chrome, Edge and some Safari builds
|
||||
* expose it as `SpeechRecognition` / `webkitSpeechRecognition`; other engines
|
||||
* do not, so the UI asks `speechInputAvailable()` first and simply hides the
|
||||
* mic when the answer is no.
|
||||
*
|
||||
* Nothing leaves the app ourselves: the audio goes to whatever recogniser the
|
||||
* browser uses, exactly like the system dictation key on a phone keyboard.
|
||||
* The API types are not in TypeScript's DOM lib, so the minimal shapes live
|
||||
* here rather than leaking `any`.
|
||||
*/
|
||||
|
||||
type RecognitionResultLike = { isFinal: boolean; 0: { transcript: string } };
|
||||
type RecognitionEventLike = { resultIndex: number; results: ArrayLike<RecognitionResultLike> };
|
||||
type RecognitionLike = {
|
||||
lang: string;
|
||||
continuous: boolean;
|
||||
interimResults: boolean;
|
||||
maxAlternatives: number;
|
||||
start(): void;
|
||||
stop(): void;
|
||||
abort(): void;
|
||||
onresult: ((event: RecognitionEventLike) => void) | null;
|
||||
onerror: (() => void) | null;
|
||||
onend: (() => void) | null;
|
||||
};
|
||||
type RecognitionCtor = new () => RecognitionLike;
|
||||
|
||||
function recognitionCtor(): RecognitionCtor | null {
|
||||
if (typeof window === "undefined") return null;
|
||||
const scope = window as unknown as { SpeechRecognition?: RecognitionCtor; webkitSpeechRecognition?: RecognitionCtor };
|
||||
return scope.SpeechRecognition ?? scope.webkitSpeechRecognition ?? null;
|
||||
}
|
||||
|
||||
export function speechInputAvailable(): boolean {
|
||||
return recognitionCtor() !== null;
|
||||
}
|
||||
|
||||
export type SpeechInput = { stop: () => void };
|
||||
|
||||
/**
|
||||
* Starts listening. Final phrases arrive through `onText` (append them to the
|
||||
* draft); `onEnd` fires exactly once when listening stops, however it stops.
|
||||
* Returns null when the browser cannot listen or `start()` fails.
|
||||
*/
|
||||
export function startSpeechInput(options: {
|
||||
/** BCP-47 tag, usually `speechLocale(deck.language, getLocale())`. */
|
||||
lang: string;
|
||||
onText: (text: string) => void;
|
||||
onEnd: () => void;
|
||||
}): SpeechInput | null {
|
||||
const Ctor = recognitionCtor();
|
||||
if (!Ctor) return null;
|
||||
let recognition: RecognitionLike;
|
||||
try {
|
||||
recognition = new Ctor();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
let finished = false;
|
||||
const finish = () => {
|
||||
if (finished) return;
|
||||
finished = true;
|
||||
options.onEnd();
|
||||
};
|
||||
|
||||
recognition.lang = options.lang;
|
||||
recognition.continuous = true;
|
||||
recognition.interimResults = true;
|
||||
recognition.maxAlternatives = 1;
|
||||
recognition.onresult = (event) => {
|
||||
for (let index = event.resultIndex; index < event.results.length; index += 1) {
|
||||
const result = event.results[index];
|
||||
if (!result?.isFinal) continue;
|
||||
const transcript = result[0]?.transcript?.trim();
|
||||
if (transcript) options.onText(transcript);
|
||||
}
|
||||
};
|
||||
recognition.onerror = finish;
|
||||
recognition.onend = finish;
|
||||
|
||||
try {
|
||||
recognition.start();
|
||||
} catch {
|
||||
finish();
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
stop() {
|
||||
try {
|
||||
recognition.stop();
|
||||
} catch {
|
||||
/* already stopped */
|
||||
}
|
||||
finish();
|
||||
},
|
||||
};
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue