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
96
lib/chat-models.ts
Normal file
96
lib/chat-models.ts
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* The chat model list and the remembered choice, shared by the Chat screen and
|
||||
* Teach it back so both offer exactly the same models and remember the same
|
||||
* pick. The server's health check lists the Ollama models; every enabled cloud
|
||||
* provider contributes its own (live listing first, built-in seeds after).
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { readStoredChatModel, readStoredChatProvider, storeChatModel, storeChatProvider, type ChatModelChoice } from "./chat";
|
||||
import { PROVIDERS } from "./providers";
|
||||
import { modelsFor, useProviders } from "./providers-client";
|
||||
import { useHealth } from "./use-health";
|
||||
|
||||
export function useChatModels(): {
|
||||
/** Everything the picker may offer. */
|
||||
models: ChatModelChoice[];
|
||||
/** The model a request should use (falls back to the first available one). */
|
||||
model: string;
|
||||
/** The provider that owns `model` ("ollama" when none). */
|
||||
provider: string;
|
||||
/** Picks a model and remembers it across visits. */
|
||||
choose: (model: string, provider?: string) => void;
|
||||
/** Restores a saved conversation's choice without remembering it. */
|
||||
restore: (model?: string, provider?: string) => void;
|
||||
} {
|
||||
const { health } = useHealth();
|
||||
const providerState = useProviders();
|
||||
const [model, setModel] = useState("");
|
||||
const [provider, setProvider] = useState("ollama");
|
||||
|
||||
useEffect(() => {
|
||||
setModel(readStoredChatModel() ?? "");
|
||||
setProvider(readStoredChatProvider());
|
||||
}, []);
|
||||
|
||||
const models = 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);
|
||||
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 && models.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 = models.find(
|
||||
(entry) => (entry.provider ?? "ollama") === activeProvider && entry.available !== false,
|
||||
);
|
||||
return (
|
||||
firstAvailable ??
|
||||
models.find((entry) => (entry.provider ?? "ollama") === activeProvider) ??
|
||||
models[0]
|
||||
)?.name ?? model;
|
||||
}, [model, models, activeProvider]);
|
||||
|
||||
const choose = useCallback((next: string, nextProvider?: string) => {
|
||||
const chosenProvider = nextProvider ?? "ollama";
|
||||
setProvider(chosenProvider);
|
||||
storeChatProvider(chosenProvider);
|
||||
setModel(next);
|
||||
storeChatModel(next);
|
||||
}, []);
|
||||
|
||||
const restore = useCallback((nextModel?: string, nextProvider?: string) => {
|
||||
if (nextModel) setModel(nextModel);
|
||||
if (nextProvider) setProvider(nextProvider);
|
||||
}, []);
|
||||
|
||||
return { models, model: activeModel, provider: activeProvider, choose, restore };
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue