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:
Martin Dvořák 2026-10-16 23:05:24 +00:00
commit 6036238bf0
25 changed files with 2821 additions and 181 deletions

View file

@ -10,28 +10,24 @@ import { HistorySheet } from "./HistoryPanel";
import { MessageRow } from "./MessageRow";
import { ModelPicker } from "./ModelPicker";
import { ChatIcon, ChatPlusIcon, ChevronDownIcon, ChevronLeftIcon, HistoryIcon, RefreshIcon } from "@/components/icons";
import { PROVIDERS, providerInfo } from "@/lib/providers";
import { modelsFor, useProviders } from "@/lib/providers-client";
import { providerInfo } from "@/lib/providers";
import { ModelErrorActions } from "@/components/ModelErrorActions";
import { ThemeToggle } from "@/components/ThemeToggle";
import { Button } from "@/components/ui";
import { ApiError } from "@/lib/client-api";
import { extractChatAttachment, streamChat } from "@/lib/chat-client";
import { useChatModels } from "@/lib/chat-models";
import { buildChatContext, deckHasNotes } from "@/lib/chat-context";
import { cn, prettyModelName } from "@/lib/utils";
import { takeChatSeed, type ChatSeed } from "@/lib/chat-seed";
import { chatActive } from "@/lib/chat-active";
import { chatHistoryStore, conversationMessages, deleteConversation, ensureChatsHydrated, getConversation, renameConversation, saveConversation } from "@/lib/chat-store";
import { useKeyboardInset } from "@/lib/use-keyboard-inset";
import {
chatMessageId,
readStoredChatModel,
readStoredChatProvider,
storeChatModel,
storeChatProvider,
titleFromMessage,
type ChatAttachment,
type ChatMessage,
type ChatModelChoice,
type ChatScope,
} from "@/lib/chat";
import { setDeckSeed } from "@/lib/deck-seed";
@ -44,7 +40,6 @@ import { showToast } from "@/lib/toast";
import { deckStore, upsertDeck } from "@/lib/storage";
import { settingsStore } from "@/lib/settings";
import type { ChatConversation } from "@/lib/schemas";
import { useHealth } from "@/lib/use-health";
import { useOnline } from "@/lib/use-online";
import { motion } from "framer-motion";
@ -65,7 +60,8 @@ export function ChatScreen() {
const decks = useSyncExternalStore(deckStore.subscribe, deckStore.getSnapshot, deckStore.getServerSnapshot);
const folders = useSyncExternalStore(folderStore.subscribe, folderStore.getSnapshot, folderStore.getServerSnapshot);
const settings = useSyncExternalStore(settingsStore.subscribe, settingsStore.getSnapshot, settingsStore.getServerSnapshot);
const { health } = useHealth();
const chatModel = useChatModels();
useKeyboardInset();
const deckParam = search.get("deck");
@ -77,8 +73,6 @@ export function ChatScreen() {
const [draft, setDraft] = useState("");
const [attachments, setAttachments] = useState<PendingAttachment[]>([]);
const [attachBusy, setAttachBusy] = useState(false);
const [model, setModel] = useState("");
const [provider, setProvider] = useState("ollama");
const [busy, setBusy] = useState(false);
const [pinned, setPinned] = useState(true);
const [scopeOpen, setScopeOpen] = useState(false);
@ -108,11 +102,6 @@ export function ChatScreen() {
if (taken.deckId) setScope({ kind: "decks", deckIds: [taken.deckId] });
}, []);
useEffect(() => {
setModel(readStoredChatModel() ?? "");
setProvider(readStoredChatProvider());
}, []);
/* History: hydrate once, then open ?c=<id> conversations. */
useEffect(() => {
void ensureChatsHydrated();
@ -123,20 +112,23 @@ export function ChatScreen() {
}, []);
const loadedRef = useRef<string | null>(null);
const loadConversation = useCallback((found: ChatConversation) => {
setConversationId(found.id);
setTitle(found.title);
setMessages(conversationMessages(found));
setScope(found.scope);
setSeed(null);
setAttachments([]);
setDraft("");
setError(null);
if (found.model) setModel(found.model);
if (found.provider) setProvider(found.provider);
loadedRef.current = found.id;
chatActive.set(found.id);
}, []);
const { restore: restoreModel } = chatModel;
const loadConversation = useCallback(
(found: ChatConversation) => {
setConversationId(found.id);
setTitle(found.title);
setMessages(conversationMessages(found));
setScope(found.scope);
setSeed(null);
setAttachments([]);
setDraft("");
setError(null);
restoreModel(found.model, found.provider);
loadedRef.current = found.id;
chatActive.set(found.id);
},
[restoreModel],
);
const conversationParam = search.get("c");
useEffect(() => {
@ -146,69 +138,9 @@ export function ChatScreen() {
loadConversation(found);
}, [conversationParam, conversations, loadConversation]);
/* Keyboard: keep the composer above the on-screen keyboard. */
useEffect(() => {
const viewport = window.visualViewport;
if (!viewport) return;
const apply = () => {
const inset = Math.max(0, window.innerHeight - viewport.height - viewport.offsetTop);
document.documentElement.style.setProperty("--chat-keyboard", `${Math.round(inset)}px`);
};
apply();
viewport.addEventListener("resize", apply);
viewport.addEventListener("scroll", apply);
return () => {
viewport.removeEventListener("resize", apply);
viewport.removeEventListener("scroll", apply);
document.documentElement.style.removeProperty("--chat-keyboard");
};
}, []);
const providerState = useProviders();
const chatModels = useMemo<ChatModelChoice[]>(() => {
const fromHealth = health?.chatModels ?? [];
const list =
fromHealth.length > 0
? fromHealth
: [
{ name: health?.text?.configured ?? "", badge: "text" as const, available: health?.text?.available ?? null },
{ name: health?.vision?.configured ?? "", badge: "vision" as const, available: health?.vision?.available ?? null },
];
const ollama = list.filter((entry) => entry.name);
// Every enabled cloud provider contributes its models (live listing first,
// the built-in seeds otherwise), labelled with the provider.
const cloud: ChatModelChoice[] = [];
for (const info of PROVIDERS) {
if (!providerState.keys[info.id]) continue;
const models = modelsFor(providerState, info.id).slice(0, 400);
if (models.length === 0) continue;
// Only a live /models listing proves the key can use them; the built-in
// seeds stay badge-less until the first successful test.
const listed = (providerState.models[info.id]?.length ?? 0) > 0;
for (const name of models) {
cloud.push({ name, badge: "text", available: listed ? true : null, provider: info.id });
}
}
return [...ollama, ...cloud];
}, [health, providerState]);
const activeProvider = provider !== "ollama" && !providerState.keys[provider] ? "ollama" : provider;
const activeModel = useMemo(() => {
if (model && chatModels.some((entry) => entry.name === model && (entry.provider ?? "ollama") === activeProvider)) {
return model;
}
// The server puts the default first, but a different server may not have
// it — fall back to the first model that is actually available.
const firstAvailable = chatModels.find(
(entry) => (entry.provider ?? "ollama") === activeProvider && entry.available !== false,
);
return (
firstAvailable ??
chatModels.find((entry) => (entry.provider ?? "ollama") === activeProvider) ??
chatModels[0]
)?.name ?? model;
}, [model, chatModels, activeProvider]);
const chatModels = chatModel.models;
const activeProvider = chatModel.provider;
const activeModel = chatModel.model;
const scopeText = useMemo(() => {
if (scope.kind === "all") return t("chat.scope.all");
@ -775,12 +707,8 @@ export function ChatScreen() {
value={activeModel}
valueProvider={activeProvider}
onChange={(next, nextProvider) => {
const chosenProvider = nextProvider ?? "ollama";
setProvider(chosenProvider);
storeChatProvider(chosenProvider);
setModel(next);
storeChatModel(next);
if (messages.length > 0) persist(messages, { model: next, provider: chosenProvider });
chatModel.choose(next, nextProvider);
if (messages.length > 0) persist(messages, { model: next, provider: nextProvider ?? "ollama" });
}}
/>
<ContextPicker