"use client"; import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react"; import { useRouter, useSearchParams } from "next/navigation"; import { motion } from "framer-motion"; import { useAppReducedMotion as useReducedMotion } from "@/lib/motion-pref"; import { AiDisclaimer } from "@/components/AiDisclaimer"; import { MessageRow } from "@/components/chat/MessageRow"; import { ModelPicker } from "@/components/chat/ModelPicker"; import { Composer, PickerChip } from "@/components/chat/Composer"; import { ContextPicker } from "@/components/chat/ContextPicker"; import { ThemeToggle } from "@/components/ThemeToggle"; import { Button } from "@/components/ui"; import { ChatIcon, ChevronLeftIcon, RefreshIcon, TeacherIcon, XIcon } from "@/components/icons"; import { ApiError } from "@/lib/client-api"; import { streamChat } from "@/lib/chat-client"; import { buildChatContext } from "@/lib/chat-context"; import { useChatModels } from "@/lib/chat-models"; import { chatMessageId, titleFromMessage, type ChatMessage, type ChatModelChoice, type ChatScope } from "@/lib/chat"; import { chatHistoryStore, conversationMessages, deleteConversation, ensureChatsHydrated, getConversation, saveConversation, } from "@/lib/chat-store"; import { folderStore } from "@/lib/folders"; import { getLocale, useI18n } from "@/lib/i18n"; import { springGentle } from "@/lib/motion"; import { providerInfo } from "@/lib/providers"; import { TEACH_SUMMARY_REPAIR_INSTRUCTION, TEACH_SUMMARY_USER_INSTRUCTION } from "@/lib/prompts"; import type { ChatConversation, Deck, Folder, TeachCardRef, TeachDifficulty, TeachSummary } from "@/lib/schemas"; import { settingsStore } from "@/lib/settings"; import { speechLocale } from "@/lib/speech"; import { startSpeechInput, speechInputAvailable, type SpeechInput } from "@/lib/speech-input"; import { deckStore } from "@/lib/storage"; import { buildTeachCards, parseTeachSummary, scopedDecks, teachCardLines, TEACH_MAX_EXCHANGES } from "@/lib/teach"; import { cn, formatDate, prettyModelName } from "@/lib/utils"; import { showToast } from "@/lib/toast"; import { useKeyboardInset } from "@/lib/use-keyboard-inset"; import { useOnline } from "@/lib/use-online"; import { TeachRecap } from "./TeachRecap"; type View = "start" | "session" | "summary"; /** * Teach it back: the user explains a topic in their own words, the AI plays a * curious student (never a grader) and checks the explanation against the * user's own cards. Sessions are chat conversations with `mode: "teach"`, so * they live in the same history, backup and sync as chats but stay out of the * Chat screens; the recap is stored with the session and can be reopened. */ export function TeachScreen() { const { t, plural } = useI18n(); const online = useOnline(); const reduceMotion = useReducedMotion(); const router = useRouter(); const search = useSearchParams(); 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 conversations = useSyncExternalStore( chatHistoryStore.subscribe, chatHistoryStore.getSnapshot, chatHistoryStore.getServerSnapshot, ); const chatModel = useChatModels(); useKeyboardInset(); const deckParam = search.get("deck"); const conversationParam = search.get("c"); const [view, setView] = useState("start"); const [topic, setTopic] = useState(""); const [difficulty, setDifficulty] = useState("friendly"); const [scope, setScope] = useState(() => deckParam ? { kind: "decks", deckIds: [deckParam] } : { kind: "all" }, ); const [cards, setCards] = useState([]); const [messages, setMessages] = useState([]); const [draft, setDraft] = useState(""); const [conversationId, setConversationId] = useState(null); const [summary, setSummary] = useState(null); const [summaryBusy, setSummaryBusy] = useState(false); const [summaryError, setSummaryError] = useState(null); const [busy, setBusy] = useState(false); const [error, setError] = useState<{ message: string; kind?: string } | null>(null); const [scopeOpen, setScopeOpen] = useState(false); const [modelOpen, setModelOpen] = useState(false); const [listening, setListening] = useState(false); const [micSupported, setMicSupported] = useState(false); const abortRef = useRef(null); const scrollRef = useRef(null); const loadedRef = useRef(null); const prefilledRef = useRef(false); const speechRef = useRef(null); const sessions = useMemo(() => conversations.filter((entry) => entry.mode === "teach"), [conversations]); useEffect(() => { void ensureChatsHydrated(); setMicSupported(speechInputAvailable()); return () => { abortRef.current?.abort(); speechRef.current?.stop(); }; }, []); /* Arriving from a deck's page: scope to it and prefill the topic once the deck store has hydrated. */ useEffect(() => { if (!deckParam || prefilledRef.current) return; const deck = decks.find((entry) => entry.id === deckParam); if (!deck) return; prefilledRef.current = true; setScope({ kind: "decks", deckIds: [deck.id] }); setTopic((current) => current || deck.title); }, [deckParam, decks]); /* Open ?c= sessions (from the recent list or a reload). */ const { restore: restoreModel } = chatModel; const loadConversation = useCallback( (found: ChatConversation) => { setConversationId(found.id); setTopic(found.teach?.topic || found.title); setDifficulty(found.teach?.difficulty ?? "friendly"); setScope(found.scope); setCards(found.teach?.cards ?? []); setMessages(conversationMessages(found)); setSummary(found.teach?.summary ?? null); setSummaryError(null); setDraft(""); setError(null); restoreModel(found.model, found.provider); loadedRef.current = found.id; setView(found.teach?.summary ? "summary" : found.messages.length > 0 ? "session" : "start"); }, [restoreModel], ); useEffect(() => { if (!conversationParam || loadedRef.current === conversationParam) return; const found = getConversation(conversationParam); if (!found || found.mode !== "teach") return; // hydration may still be running loadConversation(found); }, [conversationParam, conversations, loadConversation]); const persist = useCallback( ( nextMessages: ChatMessage[], extra: { summary?: TeachSummary | null; cards?: TeachCardRef[]; topic?: string } = {}, ) => { if (!conversationId) return; const sessionTopic = (extra.topic ?? topic).trim(); const firstUser = nextMessages.find((message) => message.role === "user"); const title = sessionTopic || (firstUser ? titleFromMessage(firstUser.content) : "") || t("teach.untitled"); const nextCards = extra.cards ?? cards; const nextSummary = extra.summary !== undefined ? extra.summary : summary; const existing = getConversation(conversationId); saveConversation({ id: conversationId, title, messages: nextMessages, scope, model: chatModel.model || undefined, provider: chatModel.provider, mode: "teach", teach: { difficulty, topic: title, scope, cards: nextCards, ...(nextSummary ? { summary: nextSummary } : {}), }, createdAt: existing?.createdAt ?? Date.now(), updatedAt: Date.now(), }); }, [conversationId, topic, cards, summary, scope, difficulty, chatModel.model, chatModel.provider, t], ); const cardLines = useMemo(() => teachCardLines(cards, decks), [cards, decks]); const sessionDecks = useMemo(() => scopedDecks(decks, scope), [decks, scope]); const voiceLanguage = useMemo(() => sessionDecks.find((deck) => deck.language)?.language, [sessionDecks]); const scopeText = useMemo(() => { if (scope.kind === "none") return t("chat.scope.none"); if (scope.kind === "all") return t("chat.scope.all"); return plural(scope.deckIds.length, "chat.scope.decks"); }, [scope, t, plural]); const userCount = messages.filter((message) => message.role === "user").length; const scrollToBottom = useCallback( (smooth = false) => { const element = scrollRef.current; if (!element) return; element.scrollTo({ top: element.scrollHeight, behavior: smooth && !reduceMotion ? "smooth" : "auto" }); }, [reduceMotion], ); useEffect(() => { if (view === "session") scrollToBottom(false); }, [messages, view, scrollToBottom]); const stopListening = useCallback(() => { speechRef.current?.stop(); speechRef.current = null; }, []); const toggleListening = () => { if (listening) { stopListening(); return; } const handle = startSpeechInput({ lang: speechLocale(voiceLanguage, getLocale()), onText: (text) => setDraft((current) => (current.trim() ? `${current.replace(/\s+$/, "")} ${text}` : text)), onEnd: () => setListening(false), }); if (handle) { speechRef.current = handle; setListening(true); } }; /* Session ------------------------------------------------------------- */ const start = () => { const trimmed = topic.trim(); if (!trimmed) return; const built = buildTeachCards({ decks, scope }); const id = chatMessageId("teach"); setConversationId(id); loadedRef.current = id; setCards(built.cards); setMessages([]); setSummary(null); setSummaryError(null); setDraft(""); setError(null); setView("session"); window.history.replaceState(null, "", `/teach?c=${id}`); }; const run = async (history: ChatMessage[]) => { const lastUser = history[history.length - 1]; const context = buildChatContext({ decks, scope, question: lastUser.content, history: history.slice(0, -1), useNotes: settings.chatNotes, }); const assistantId = chatMessageId("s"); const exchange = history.filter((message) => message.role === "user").length; setMessages([...history, { id: assistantId, role: "assistant", content: "", at: Date.now() }]); setBusy(true); const controller = new AbortController(); abortRef.current = controller; let produced = false; let finalContent = ""; let failure: { message: string; kind?: string } | null = null; try { await streamChat({ model: chatModel.model || undefined, provider: chatModel.provider, mode: "teach", teach: { topic: topic.trim(), difficulty, exchange, limit: TEACH_MAX_EXCHANGES, cards: cardLines, }, messages: history.map((message) => ({ role: message.role, content: message.content })), context: context.chunks.map(({ deckId, deckTitle, section, text }) => ({ deckId, deckTitle, section, text })), signal: controller.signal, onToken: (text) => { produced = true; finalContent += text; setMessages((previous) => previous.map((message) => message.id === assistantId ? { ...message, content: message.content + text } : message, ), ); }, onThinking: () => { /* the student does not show its thinking */ }, onDone: (modelName) => { setMessages((previous) => previous.map((message) => (message.id === assistantId ? { ...message, model: modelName } : message)), ); }, }); } catch (caught) { const apiError = caught instanceof ApiError ? caught : new ApiError(String(caught), "unknown"); if (apiError.kind === "aborted") { // Stopped by the user: keep whatever arrived. } else { failure = { message: apiError.message, kind: apiError.kind }; setError(failure); if (!produced) { setMessages(history); setDraft(lastUser.content); } else { setMessages((previous) => previous.map((message) => message.id === assistantId ? { ...message, error: failure ?? undefined } : message, ), ); } } } finally { abortRef.current = null; setBusy(false); const assistant: ChatMessage | null = produced ? { id: assistantId, role: "assistant", content: finalContent, at: Date.now(), ...(failure ? { error: failure } : {}) } : null; const next = assistant ? [...history, assistant] : history; persist(next); /* A reasonable number of exchanges: the student wraps up on its own. */ if (produced && exchange >= TEACH_MAX_EXCHANGES) { showToast({ message: t("teach.limitReached") }); void runRecap(next); } } }; const send = (text?: string) => { const content = (text ?? draft).trim(); if (!content || busy || !online || !conversationId) return; stopListening(); const userMessage: ChatMessage = { id: chatMessageId("u"), role: "user", content, at: Date.now() }; const history = [...messages, userMessage]; setError(null); setMessages(history); setDraft(""); void run(history); }; const retryLast = useCallback(() => { if (busy) return; const lastUserIndex = messages.map((message) => message.role).lastIndexOf("user"); if (lastUserIndex < 0) return; const history = messages.slice(0, lastUserIndex + 1); setError(null); setMessages(history); void run(history); // eslint-disable-next-line react-hooks/exhaustive-deps }, [busy, messages]); const editLast = useCallback(() => { if (busy) return; const lastUserIndex = messages.map((message) => message.role).lastIndexOf("user"); if (lastUserIndex < 0) return; setDraft(messages[lastUserIndex].content); setMessages(messages.slice(0, lastUserIndex)); }, [busy, messages]); /* Recap --------------------------------------------------------------- */ const runRecap = async (source: ChatMessage[]) => { const usable = source.filter((message) => message.role === "user" || message.content.trim()); if (usable.length === 0) { setView("start"); return; } setView("summary"); setSummaryError(null); setSummaryBusy(true); const context = buildChatContext({ decks, scope, question: topic.trim() || "recap", history: usable, useNotes: settings.chatNotes, budgetTokens: 6000, }); const contextChunks = context.chunks.map(({ deckId, deckTitle, section, text }) => ({ deckId, deckTitle, section, text })); const base = usable.map((message) => ({ role: message.role, content: message.content })); let repair: string | null = null; for (let attempt = 0; attempt < 2; attempt += 1) { const turns = repair ? [ ...base, { role: "user" as const, content: TEACH_SUMMARY_USER_INSTRUCTION }, { role: "assistant" as const, content: repair.slice(0, 8000) }, { role: "user" as const, content: TEACH_SUMMARY_REPAIR_INSTRUCTION }, ] : [...base, { role: "user" as const, content: TEACH_SUMMARY_USER_INSTRUCTION }]; let raw = ""; try { await streamChat({ model: chatModel.model || undefined, provider: chatModel.provider, mode: "teach_summary", teach: { topic: topic.trim(), difficulty, exchange: userCount, limit: TEACH_MAX_EXCHANGES, cards: cardLines, }, messages: turns, context: contextChunks, onToken: (text) => { raw += text; }, onThinking: () => {}, onDone: () => {}, }); } catch (caught) { const apiError = caught instanceof ApiError ? caught : new ApiError(String(caught), "unknown"); if (apiError.kind !== "aborted") setSummaryError(apiError.message); setSummaryBusy(false); return; } const parsed = parseTeachSummary(raw, cards); if (parsed) { setSummary(parsed); setSummaryBusy(false); persist(usable, { summary: parsed }); return; } repair = raw; } setSummaryError(t("teach.summaryFailed")); setSummaryBusy(false); }; const finish = () => { if (busy) return; stopListening(); void runRecap(messages); }; const resetSession = useCallback(() => { abortRef.current?.abort(); stopListening(); loadedRef.current = null; setConversationId(null); setMessages([]); setCards([]); setSummary(null); setSummaryError(null); setDraft(""); setError(null); setView("start"); window.history.replaceState(null, "", "/teach"); }, [stopListening]); const openSession = (found: ChatConversation) => { abortRef.current?.abort(); loadConversation(found); window.history.replaceState(null, "", `/teach?c=${found.id}`); }; const removeSession = (found: ChatConversation) => { deleteConversation(found.id); if (found.id === conversationId) resetSession(); showToast({ message: t("teach.deleted"), actionLabel: t("toast.undo"), onAction: () => saveConversation(found), }); }; const modelLabel = chatModel.model ? chatModel.provider !== "ollama" ? `${providerInfo(chatModel.provider)?.label ?? chatModel.provider} · ${prettyModelName(chatModel.model)}` : prettyModelName(chatModel.model) : t("chat.modelTitle"); /* Render --------------------------------------------------------------- */ return (

{view === "session" ? topic : view === "summary" ? t("teach.summaryTitle") : t("teach.title")}

{view === "session" ? `${t("teach.youExplain")} · ${difficulty === "friendly" ? t("teach.friendly") : t("teach.skeptical")} · ${t("teach.exchangeCount", { n: userCount, max: TEACH_MAX_EXCHANGES })}` : t("teach.subtitle")}

{view === "session" ? ( ) : ( <> {view === "summary" ? ( ) : null} )}
{view === "start" ? ( { setScopeOpen((open) => !open); setModelOpen(false); }} onOpenModel={() => { setModelOpen((open) => !open); setScopeOpen(false); }} decks={decks} folders={folders} scope={scope} onScopeChange={setScope} onCloseScope={() => setScopeOpen(false)} chatModels={chatModel.models} activeModel={chatModel.model} activeProvider={chatModel.provider} onChooseModel={(model, provider) => chatModel.choose(model, provider)} onCloseModel={() => setModelOpen(false)} online={online} sessions={sessions} onStart={start} onOpenSession={openSession} onRemoveSession={removeSession} /> ) : null} {view === "session" ? ( <>
{messages.length === 0 ? (

{t("teach.emptyTitle")}

{t("teach.emptyHint")}

{t("teach.studentLabel")}: {scopeText}

) : ( messages.map((message, index) => ( )) )}
{error ? (

{error.message}

{messages.some((message) => message.role === "user") ? (
) : null}
) : null}
send()} onStop={() => abortRef.current?.abort()} busy={busy} online={online} modelLabel={modelLabel} modelTitle={chatModel.model ? `${chatModel.model} · ${providerInfo(chatModel.provider)?.label ?? chatModel.provider}` : undefined} modelOpen={modelOpen} onOpenModel={() => setModelOpen((open) => !open)} placeholder={t("teach.placeholder")} onVoice={micSupported ? toggleListening : undefined} listening={listening} voiceTitle={listening ? t("teach.voiceStop") : t("teach.voiceStart")} > {listening ? (

{t("teach.listening")}

) : null}
setModelOpen(false)} models={chatModel.models} value={chatModel.model} valueProvider={chatModel.provider} onChange={(next, nextProvider) => chatModel.choose(next, nextProvider)} />
) : null} {view === "summary" ? ( void runRecap(messages)} onBackToSession={() => setView("session")} /> ) : null}
); } /* ------------------------------------------------------------------ */ /* Start view */ /* ------------------------------------------------------------------ */ function StartView({ topic, setTopic, difficulty, setDifficulty, scopeText, scopeEmpty, scopeOpen, modelOpen, modelLabel, onOpenScope, onOpenModel, decks, folders, scope, onScopeChange, onCloseScope, chatModels, activeModel, activeProvider, onChooseModel, onCloseModel, online, sessions, onStart, onOpenSession, onRemoveSession, }: { topic: string; setTopic: (value: string) => void; difficulty: TeachDifficulty; setDifficulty: (value: TeachDifficulty) => void; scopeText: string; scopeEmpty: boolean; scopeOpen: boolean; modelOpen: boolean; modelLabel: string; onOpenScope: () => void; onOpenModel: () => void; decks: Deck[]; folders: Folder[]; scope: ChatScope; onScopeChange: (scope: ChatScope) => void; onCloseScope: () => void; chatModels: ChatModelChoice[]; activeModel: string; activeProvider: string; onChooseModel: (model: string, provider?: string) => void; onCloseModel: () => void; online: boolean; sessions: ChatConversation[]; onStart: () => void; onOpenSession: (conversation: ChatConversation) => void; onRemoveSession: (conversation: ChatConversation) => void; }) { const { t } = useI18n(); const choice = (value: TeachDifficulty, title: string, note: string) => ( ); return (

{t("teach.title")}

{t("teach.intro")}

setTopic(event.target.value)} placeholder={t("teach.topicPlaceholder")} maxLength={200} autoComplete="off" className="w-full rounded-[var(--radius-control)] border border-[var(--glass-border)] bg-[var(--surface-strong)] px-3.5 py-2.5 text-[16px] text-[var(--text)] outline-none transition placeholder:text-[var(--text-subtle)] focus:border-accent-400/60 sm:text-[13.5px]" />
{t("teach.difficultyLabel")}
{choice("friendly", t("teach.friendly"), t("teach.friendlyNote"))} {choice("skeptical", t("teach.skeptical"), t("teach.skepticalNote"))}
{t("teach.studentLabel")}
} label={scopeText} open={scopeOpen} onClick={onOpenScope} /> } label={modelLabel} title={activeModel || modelLabel} open={modelOpen} onClick={onOpenModel} />
{scopeEmpty ?

{t("teach.noDecksHint")}

: null}

{t("teach.recent")}

{sessions.length === 0 ? (

{t("teach.noSessions")}

) : (
    {sessions.slice(0, 8).map((session) => (
  • ))}
)}
); }