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
|
|
@ -17,7 +17,7 @@ import { showToast } from "@/lib/toast";
|
|||
import { useBackClose } from "@/lib/use-back-close";
|
||||
import { useIsPhone } from "@/lib/use-phone";
|
||||
import { cn, formatDate, truncate } from "@/lib/utils";
|
||||
import { ChatIcon, ChevronDownIcon, DownloadIcon, EllipsisIcon, FolderIcon, PlayIcon, RefreshIcon, ShareIcon, XCircleIcon } from "@/components/icons";
|
||||
import { ChatIcon, ChevronDownIcon, DownloadIcon, EllipsisIcon, FolderIcon, PlayIcon, RefreshIcon, ShareIcon, TeacherIcon, XCircleIcon } from "@/components/icons";
|
||||
import { Button, ProgressBar } from "@/components/ui";
|
||||
|
||||
type Busy = null | "more" | "regenerate";
|
||||
|
|
@ -395,6 +395,13 @@ export function DeckCard({
|
|||
<ChatIcon className="size-4 text-[var(--text-subtle)]" />
|
||||
{t("chat.askAboutDeck")}
|
||||
</Link>
|
||||
<Link
|
||||
href={`/teach?deck=${deck.id}`}
|
||||
className={menuRow}
|
||||
>
|
||||
<TeacherIcon className="size-4 text-[var(--text-subtle)]" />
|
||||
{t("teach.nav")}
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
className={menuRow}
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import {
|
|||
PlusIcon,
|
||||
SparklesIcon,
|
||||
SunIcon,
|
||||
TeacherIcon,
|
||||
} from "@/components/icons";
|
||||
import { useTheme } from "@/lib/theme";
|
||||
import { createFolder, folderStore } from "@/lib/folders";
|
||||
|
|
@ -276,6 +277,14 @@ function NavContent({ collapsed = false }: { collapsed?: boolean }) {
|
|||
<CompassIcon className="size-4 shrink-0 opacity-70" />
|
||||
{!collapsed ? <span className="flex-1 truncate text-left">{t("nav.exam")}</span> : null}
|
||||
</Link>
|
||||
<Link
|
||||
href="/teach"
|
||||
title={t("teach.nav")}
|
||||
className={cn("nav-row", collapsed && "justify-center px-2")}
|
||||
>
|
||||
<TeacherIcon className="size-4 shrink-0 opacity-70" />
|
||||
{!collapsed ? <span className="flex-1 truncate text-left">{t("teach.nav")}</span> : null}
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openSettings()}
|
||||
|
|
|
|||
|
|
@ -27,9 +27,9 @@ export function TabBar() {
|
|||
const reduceMotion = useReducedMotion();
|
||||
|
||||
/* The wizards keep their own bottom action bar (Cancel / Next / Freeze) and
|
||||
chat keeps its own composer — the flow is deliberately focused, so the tab
|
||||
bar steps aside there. */
|
||||
const inWizard = pathname === "/create" || pathname === "/homework" || pathname === "/chat";
|
||||
the chat and teach screens keep their own composer — those flows are
|
||||
deliberately focused, so the tab bar steps aside there. */
|
||||
const inWizard = pathname === "/create" || pathname === "/homework" || pathname === "/chat" || pathname === "/teach";
|
||||
|
||||
const item =
|
||||
"relative flex min-h-[52px] min-w-0 flex-1 flex-col items-center justify-center gap-1 py-2 text-[10.5px] font-medium leading-none transition-colors";
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
import { useEffect, useRef } from "react";
|
||||
|
||||
import { AiDisclaimer } from "@/components/AiDisclaimer";
|
||||
import { ChatIcon, LayersIcon, PaperclipIcon, SendIcon, StopIcon, XIcon } from "@/components/icons";
|
||||
import { ChatIcon, LayersIcon, MicIcon, PaperclipIcon, SendIcon, StopIcon, XIcon } from "@/components/icons";
|
||||
import { Spinner } from "@/components/ui";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
import { useIsPhone } from "@/lib/use-phone";
|
||||
|
|
@ -16,6 +16,10 @@ export type PendingAttachment = { attachment: ChatAttachment; text: string };
|
|||
* The pinned composer: auto-growing 16px textarea, send/stop, attach, and the
|
||||
* context + model chips. Enter sends on desktop, newline on phones; the
|
||||
* keyboard inset is handled by the screen around it.
|
||||
*
|
||||
* Teach it back reuses it with the scope chip and the attach button off and
|
||||
* the mic on (when the browser can dictate), so the two screens share one
|
||||
* input, one disclaimer and one chip language.
|
||||
*/
|
||||
export function Composer({
|
||||
value,
|
||||
|
|
@ -35,6 +39,11 @@ export function Composer({
|
|||
attachBusy,
|
||||
attachments,
|
||||
onRemoveAttachment,
|
||||
placeholder,
|
||||
onVoice,
|
||||
listening,
|
||||
voiceTitle,
|
||||
children,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
|
|
@ -42,23 +51,33 @@ export function Composer({
|
|||
onStop: () => void;
|
||||
busy: boolean;
|
||||
online: boolean;
|
||||
scopeLabel: string;
|
||||
/** Shown on the context chip; the chip is hidden without it. */
|
||||
scopeLabel?: string;
|
||||
modelLabel: string;
|
||||
/** The raw model name, shown as the chip's tooltip. */
|
||||
modelTitle?: string;
|
||||
scopeOpen: boolean;
|
||||
scopeOpen?: boolean;
|
||||
modelOpen: boolean;
|
||||
onOpenScope: () => void;
|
||||
onOpenScope?: () => void;
|
||||
onOpenModel: () => void;
|
||||
onAttachFile: (file: File) => void;
|
||||
attachBusy: boolean;
|
||||
attachments: PendingAttachment[];
|
||||
onRemoveAttachment: (index: number) => void;
|
||||
/** Omitted → no attach button (Teach it back takes no attachments). */
|
||||
onAttachFile?: (file: File) => void;
|
||||
attachBusy?: boolean;
|
||||
attachments?: PendingAttachment[];
|
||||
onRemoveAttachment?: (index: number) => void;
|
||||
placeholder?: string;
|
||||
/** Dictation (Teach it back): shown only when the browser supports it. */
|
||||
onVoice?: () => void;
|
||||
listening?: boolean;
|
||||
voiceTitle?: string;
|
||||
/** Extra row above the chips (e.g. the teach mode's hint). */
|
||||
children?: React.ReactNode;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const isPhone = useIsPhone();
|
||||
const textarea = useRef<HTMLTextAreaElement>(null);
|
||||
const fileInput = useRef<HTMLInputElement>(null);
|
||||
const pending = attachments ?? [];
|
||||
|
||||
useEffect(() => {
|
||||
const element = textarea.current;
|
||||
|
|
@ -71,9 +90,9 @@ export function Composer({
|
|||
|
||||
return (
|
||||
<div className="chat-composer-band glass-panel px-4 pb-safe pt-2 lg:px-10">
|
||||
{attachments.length > 0 ? (
|
||||
{pending.length > 0 ? (
|
||||
<ul className="mb-2 flex flex-wrap gap-1.5">
|
||||
{attachments.map((entry, index) => (
|
||||
{pending.map((entry, index) => (
|
||||
<li
|
||||
key={`${entry.attachment.name}-${index}`}
|
||||
className="flex items-center gap-1.5 rounded-[var(--radius-chip)] border border-[var(--glass-border)] bg-[var(--surface-strong)] py-1 pl-2.5 pr-1 text-[12px] text-[var(--text-muted)]"
|
||||
|
|
@ -84,7 +103,7 @@ export function Composer({
|
|||
type="button"
|
||||
aria-label={t("chat.attachRemove")}
|
||||
title={t("chat.attachRemove")}
|
||||
onClick={() => onRemoveAttachment(index)}
|
||||
onClick={() => onRemoveAttachment?.(index)}
|
||||
className="grid size-6 place-items-center rounded-[var(--radius-chip)] text-[var(--text-subtle)] transition hover:bg-[var(--hover)] hover:text-[var(--text)]"
|
||||
>
|
||||
<XIcon className="size-3.5" />
|
||||
|
|
@ -95,29 +114,52 @@ export function Composer({
|
|||
) : null}
|
||||
|
||||
<div className="flex items-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t("chat.attach")}
|
||||
title={t("chat.attach")}
|
||||
disabled={attachBusy}
|
||||
onClick={() => fileInput.current?.click()}
|
||||
className="touch-target grid size-10 shrink-0 place-items-center rounded-[var(--radius-control)] border border-[var(--glass-border)] bg-[var(--surface-strong)] text-[var(--text-muted)] transition hover:bg-[var(--hover)] hover:text-[var(--text)] disabled:opacity-60"
|
||||
>
|
||||
{attachBusy ? <Spinner className="size-4" /> : <PaperclipIcon className="size-5" />}
|
||||
</button>
|
||||
<input
|
||||
ref={fileInput}
|
||||
type="file"
|
||||
aria-hidden="true"
|
||||
tabIndex={-1}
|
||||
accept="image/*,application/pdf,.docx,.pptx,.odt,.txt,.md,.csv"
|
||||
className="sr-only"
|
||||
onChange={(event) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (file) onAttachFile(file);
|
||||
event.target.value = "";
|
||||
}}
|
||||
/>
|
||||
{onAttachFile ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t("chat.attach")}
|
||||
title={t("chat.attach")}
|
||||
disabled={attachBusy}
|
||||
onClick={() => fileInput.current?.click()}
|
||||
className="touch-target grid size-10 shrink-0 place-items-center rounded-[var(--radius-control)] border border-[var(--glass-border)] bg-[var(--surface-strong)] text-[var(--text-muted)] transition hover:bg-[var(--hover)] hover:text-[var(--text)] disabled:opacity-60"
|
||||
>
|
||||
{attachBusy ? <Spinner className="size-4" /> : <PaperclipIcon className="size-5" />}
|
||||
</button>
|
||||
<input
|
||||
ref={fileInput}
|
||||
type="file"
|
||||
aria-hidden="true"
|
||||
tabIndex={-1}
|
||||
accept="image/*,application/pdf,.docx,.pptx,.odt,.txt,.md,.csv"
|
||||
className="sr-only"
|
||||
onChange={(event) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (file) onAttachFile(file);
|
||||
event.target.value = "";
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{onVoice ? (
|
||||
<button
|
||||
type="button"
|
||||
data-voice-button
|
||||
aria-pressed={listening ?? false}
|
||||
aria-label={listening ? t("chat.voiceStop") : t("chat.voiceStart")}
|
||||
title={voiceTitle ?? (listening ? t("chat.voiceStop") : t("chat.voiceStart"))}
|
||||
onClick={onVoice}
|
||||
className={cn(
|
||||
"touch-target grid size-10 shrink-0 place-items-center rounded-[var(--radius-control)] border transition",
|
||||
listening
|
||||
? "border-rose-400/70 bg-rose-500/15 text-rose-600 dark:text-rose-300"
|
||||
: "border-[var(--glass-border)] bg-[var(--surface-strong)] text-[var(--text-muted)] hover:bg-[var(--hover)] hover:text-[var(--text)]",
|
||||
)}
|
||||
>
|
||||
<MicIcon className={cn("size-5", listening && "animate-pulse motion-reduce:animate-none")} />
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
<textarea
|
||||
ref={textarea}
|
||||
|
|
@ -130,8 +172,8 @@ export function Composer({
|
|||
if (canSend) onSend();
|
||||
}}
|
||||
rows={1}
|
||||
placeholder={t("chat.placeholder")}
|
||||
aria-label={t("chat.placeholder")}
|
||||
placeholder={placeholder ?? t("chat.placeholder")}
|
||||
aria-label={placeholder ?? t("chat.placeholder")}
|
||||
className="no-scrollbar max-h-[180px] min-h-[44px] min-w-0 flex-1 resize-none rounded-[var(--radius-control)] border border-[var(--glass-border)] bg-[var(--surface-strong)] px-3.5 py-2.5 text-[16px] leading-relaxed text-[var(--text)] outline-none transition-[height,border-color] duration-150 ease-out placeholder:text-[var(--text-subtle)] focus:border-accent-400/60 motion-reduce:transition-none"
|
||||
/>
|
||||
|
||||
|
|
@ -160,29 +202,21 @@ export function Composer({
|
|||
</div>
|
||||
|
||||
<div className="mt-2 flex items-center gap-2 overflow-x-auto">
|
||||
<button
|
||||
type="button"
|
||||
data-picker-trigger
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={scopeOpen}
|
||||
onClick={onOpenScope}
|
||||
className={pickerChipClass(scopeOpen)}
|
||||
>
|
||||
<LayersIcon className="size-4 shrink-0" />
|
||||
<span className="max-w-[190px] truncate">{scopeLabel}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-picker-trigger
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={modelOpen}
|
||||
onClick={onOpenModel}
|
||||
{scopeLabel && onOpenScope ? (
|
||||
<PickerChip
|
||||
icon={<LayersIcon className="size-4 shrink-0" />}
|
||||
label={scopeLabel}
|
||||
open={scopeOpen ?? false}
|
||||
onClick={onOpenScope}
|
||||
/>
|
||||
) : null}
|
||||
<PickerChip
|
||||
icon={<ChatIcon className="size-4 shrink-0" />}
|
||||
label={modelLabel}
|
||||
title={modelTitle ?? modelLabel}
|
||||
className={pickerChipClass(modelOpen)}
|
||||
>
|
||||
<ChatIcon className="size-4 shrink-0" />
|
||||
<span className="max-w-[190px] truncate">{modelLabel}</span>
|
||||
</button>
|
||||
open={modelOpen}
|
||||
onClick={onOpenModel}
|
||||
/>
|
||||
{!online ? (
|
||||
<span role="status" className="ml-auto shrink-0 text-[11.5px] text-amber-600 dark:text-amber-300">
|
||||
{t("chat.offlineNote")}
|
||||
|
|
@ -190,19 +224,47 @@ export function Composer({
|
|||
) : null}
|
||||
</div>
|
||||
|
||||
{children}
|
||||
|
||||
<AiDisclaimer plain className="mt-1.5" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Chip style for the two popover triggers (context, model): the shared chip
|
||||
* language, read as pressed while the chip's own popover is open (the same
|
||||
* chip opens and closes it).
|
||||
* The chip that opens a picker (context, model): the shared chip language,
|
||||
* read as pressed while its own popover is open. Exported so screens that are
|
||||
* not the full chat composer (Teach it back's start screen) offer the exact
|
||||
* same trigger.
|
||||
*/
|
||||
function pickerChipClass(open: boolean): string {
|
||||
return cn(
|
||||
"chip min-w-0 px-3 py-2 text-[12.5px] font-medium",
|
||||
open && "border-[var(--nav-active-border)] bg-[var(--nav-active-bg)]",
|
||||
export function PickerChip({
|
||||
icon,
|
||||
label,
|
||||
title,
|
||||
open,
|
||||
onClick,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
title?: string;
|
||||
open: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
data-picker-trigger
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={open}
|
||||
onClick={onClick}
|
||||
title={title ?? label}
|
||||
className={cn(
|
||||
"chip min-w-0 px-3 py-2 text-[12.5px] font-medium",
|
||||
open && "border-[var(--nav-active-border)] bg-[var(--nav-active-bg)]",
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
<span className="max-w-[190px] truncate">{label}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -38,8 +38,10 @@ export function ChatHistoryList({
|
|||
const [renaming, setRenaming] = useState<string | null>(null);
|
||||
const [draft, setDraft] = useState("");
|
||||
|
||||
const visible = conversations.filter((conversation) =>
|
||||
conversationMatches(conversation.title, conversation.messages, query),
|
||||
const visible = conversations.filter(
|
||||
(conversation) =>
|
||||
/* Teach it back sessions live in another screen's history. */
|
||||
conversation.mode !== "teach" && conversationMatches(conversation.title, conversation.messages, query),
|
||||
);
|
||||
|
||||
const startRename = (conversation: ChatConversation) => {
|
||||
|
|
|
|||
|
|
@ -337,6 +337,29 @@ export function ChatIcon({ className }: IconProps) {
|
|||
);
|
||||
}
|
||||
|
||||
/* --- Teach it back ------------------------------------------------------ */
|
||||
|
||||
/** Graduation cap: the Teach it back mode in the sidebar and deck menus. */
|
||||
export function TeacherIcon({ className }: IconProps) {
|
||||
return (
|
||||
<Svg className={className}>
|
||||
<path d="M2.5 9.5 12 5l9.5 4.5L12 14 2.5 9.5Z" />
|
||||
<path d="M6.5 11.5v4.2c0 .8 2.5 2.3 5.5 2.3s5.5-1.5 5.5-2.3v-4.2" />
|
||||
<path d="M21.5 9.5v4" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function MicIcon({ className }: IconProps) {
|
||||
return (
|
||||
<Svg className={className}>
|
||||
<rect x="9" y="3" width="6" height="11" rx="3" />
|
||||
<path d="M5.5 11.5a6.5 6.5 0 0 0 13 0" />
|
||||
<path d="M12 18v3" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function SendIcon({ className }: IconProps) {
|
||||
return (
|
||||
<Svg className={className}>
|
||||
|
|
|
|||
361
components/teach/TeachRecap.tsx
Normal file
361
components/teach/TeachRecap.tsx
Normal file
|
|
@ -0,0 +1,361 @@
|
|||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import Link from "next/link";
|
||||
|
||||
import { AiDisclaimer } from "@/components/AiDisclaimer";
|
||||
import { Button, Spinner } from "@/components/ui";
|
||||
import { RefreshIcon, SparklesIcon } from "@/components/icons";
|
||||
import { ApiError, generateDeck } from "@/lib/client-api";
|
||||
import { deckHasNotes, generatedDeckText } from "@/lib/chat-context";
|
||||
import { materialiseActivities, activityTypes } from "@/lib/deck-ops";
|
||||
import { scheduleForReview } from "@/lib/history";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
import type { Deck, TeachCardRef, TeachDifficulty, TeachSummary } from "@/lib/schemas";
|
||||
import { upsertDeck } from "@/lib/storage";
|
||||
import { gapCardFrom, gapFocus } from "@/lib/teach";
|
||||
import { showToast } from "@/lib/toast";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type ProposedCard = { id: string; front: string; back: string; include: boolean };
|
||||
|
||||
/**
|
||||
* The recap of a Teach it back session: what was explained well, what was
|
||||
* missed or unclear (linked to the user's own cards when one covers it), and
|
||||
* the next steps — plus the two follow-ups the recap feeds: putting the missed
|
||||
* cards on the review schedule, and turning the gaps into new flashcards
|
||||
* (generated from the deck's own notes, editable before saving).
|
||||
*/
|
||||
export function TeachRecap({
|
||||
topic,
|
||||
difficulty,
|
||||
cards,
|
||||
decks,
|
||||
summary,
|
||||
busy,
|
||||
error,
|
||||
onRetry,
|
||||
onBackToSession,
|
||||
}: {
|
||||
topic: string;
|
||||
difficulty: TeachDifficulty;
|
||||
cards: TeachCardRef[];
|
||||
decks: Deck[];
|
||||
summary: TeachSummary | null;
|
||||
busy: boolean;
|
||||
error: string | null;
|
||||
onRetry: () => void;
|
||||
onBackToSession: () => void;
|
||||
}) {
|
||||
const { t, plural } = useI18n();
|
||||
|
||||
/* Cards the recap linked a gap to — the ones "add to review" schedules. */
|
||||
const linked = useMemo(() => {
|
||||
const refs = new Set((summary?.gaps ?? []).map((gap) => gap.ref).filter((ref): ref is number => ref != null));
|
||||
return cards.filter((card) => refs.has(card.ref));
|
||||
}, [summary, cards]);
|
||||
|
||||
const addToReview = () => {
|
||||
const byDeck = new Map<string, string[]>();
|
||||
for (const card of linked) {
|
||||
const list = byDeck.get(card.deckId) ?? [];
|
||||
list.push(card.activityId);
|
||||
byDeck.set(card.deckId, list);
|
||||
}
|
||||
let count = 0;
|
||||
for (const [deckId, activityIds] of byDeck) count += scheduleForReview(deckId, activityIds);
|
||||
showToast({ message: count > 0 ? plural(count, "teach.reviewAdded") : t("teach.gapsEmpty") });
|
||||
};
|
||||
|
||||
const defaultDeckId = linked[0]?.deckId ?? cards[0]?.deckId ?? decks[0]?.id ?? "";
|
||||
|
||||
return (
|
||||
<div className="chat-scroll relative min-h-0 flex-1 overflow-y-auto overscroll-contain">
|
||||
<div className="mx-auto w-full max-w-3xl space-y-4 px-4 py-4 lg:px-0 lg:py-6" data-testid="teach-recap">
|
||||
<p className="px-1 text-[12.5px] text-[var(--text-subtle)]">
|
||||
{topic} · {difficulty === "friendly" ? t("teach.friendly") : t("teach.skeptical")}
|
||||
</p>
|
||||
|
||||
{busy ? (
|
||||
<section className="card flex items-center gap-3 p-5" role="status">
|
||||
<Spinner className="size-5 text-accent-600 dark:text-accent-200" />
|
||||
<span className="text-[13.5px] text-[var(--text-muted)]">{t("teach.summaryBusy")}</span>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{!busy && error ? (
|
||||
<section className="card space-y-3 p-5">
|
||||
<p className="text-[13.5px] leading-relaxed text-rose-700 dark:text-rose-300">{error}</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button size="sm" variant="secondary" onClick={onRetry}>
|
||||
<RefreshIcon className="size-4" /> {t("teach.retry")}
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={onBackToSession}>
|
||||
{t("teach.back")}
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{!busy && summary ? (
|
||||
<>
|
||||
<section className="card space-y-5 p-4 sm:p-5">
|
||||
<RecapList
|
||||
title={t("teach.well")}
|
||||
tone="good"
|
||||
items={summary.well}
|
||||
empty={t("teach.wellEmpty")}
|
||||
/>
|
||||
<div>
|
||||
<h3 className="mb-2 text-[12px] font-semibold uppercase tracking-wider text-[var(--text-subtle)]">
|
||||
{t("teach.gaps")}
|
||||
</h3>
|
||||
{summary.gaps.length === 0 ? (
|
||||
<p className="text-[13.5px] text-[var(--text-muted)]">{t("teach.gapsEmpty")}</p>
|
||||
) : (
|
||||
<ul className="space-y-2.5">
|
||||
{summary.gaps.map((gap, index) => {
|
||||
const card = gap.ref != null ? cards.find((entry) => entry.ref === gap.ref) : undefined;
|
||||
return (
|
||||
<li key={index} className="text-[13.5px] leading-relaxed text-[var(--text)]">
|
||||
<span>{gap.what}</span>
|
||||
{card ? (
|
||||
<Link
|
||||
href={`/deck/${card.deckId}`}
|
||||
title={t("teach.cardLinkTitle", { deck: card.deckTitle })}
|
||||
className="mt-1 flex w-fit max-w-full items-center gap-1.5 truncate rounded-[var(--radius-chip)] border border-[var(--glass-border)] bg-[var(--surface-strong)] px-2 py-1 text-[11.5px] text-[var(--text-muted)] transition hover:text-[var(--text)]"
|
||||
>
|
||||
<span className="shrink-0 font-semibold">{card.deckTitle}</span>
|
||||
<span className="truncate opacity-80">{card.label}</span>
|
||||
</Link>
|
||||
) : null}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
<RecapList title={t("teach.next")} tone="next" items={summary.next} empty={t("teach.wellEmpty")} />
|
||||
</section>
|
||||
|
||||
<section className="flex flex-wrap gap-2">
|
||||
{linked.length > 0 ? (
|
||||
<Button variant="secondary" className="touch-target" onClick={addToReview} data-testid="teach-review">
|
||||
<RefreshIcon className="size-4" /> {t("teach.addToReview")}
|
||||
</Button>
|
||||
) : null}
|
||||
{summary.gaps.length > 0 ? (
|
||||
<GapCards topic={topic} gaps={summary.gaps} decks={decks} defaultDeckId={defaultDeckId} />
|
||||
) : null}
|
||||
</section>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RecapList({ title, tone, items, empty }: { title: string; tone: "good" | "next"; items: string[]; empty: string }) {
|
||||
return (
|
||||
<div>
|
||||
<h3 className="mb-2 text-[12px] font-semibold uppercase tracking-wider text-[var(--text-subtle)]">{title}</h3>
|
||||
{items.length === 0 ? (
|
||||
<p className="text-[13.5px] text-[var(--text-muted)]">{empty}</p>
|
||||
) : (
|
||||
<ul className="space-y-1.5">
|
||||
{items.map((item, index) => (
|
||||
<li key={index} className="flex gap-2 text-[13.5px] leading-relaxed text-[var(--text)]">
|
||||
<span aria-hidden className={cn("mt-[7px] size-1.5 shrink-0 rounded-full", tone === "good" ? "bg-emerald-500" : "bg-accent-500")} />
|
||||
<span>{item}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* "Turn gaps into cards": one flashcard per gap, generated from the chosen
|
||||
* deck's own notes, reviewed and edited here before anything is saved.
|
||||
*/
|
||||
function GapCards({
|
||||
topic,
|
||||
gaps,
|
||||
decks,
|
||||
defaultDeckId,
|
||||
}: {
|
||||
topic: string;
|
||||
gaps: { what: string }[];
|
||||
decks: Deck[];
|
||||
defaultDeckId: string;
|
||||
}) {
|
||||
const { t, plural } = useI18n();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [deckId, setDeckId] = useState(defaultDeckId);
|
||||
const [generating, setGenerating] = useState(false);
|
||||
const [generateError, setGenerateError] = useState<string | null>(null);
|
||||
const [proposed, setProposed] = useState<ProposedCard[] | null>(null);
|
||||
const [savedCount, setSavedCount] = useState<number | null>(null);
|
||||
|
||||
const deck = decks.find((entry) => entry.id === deckId);
|
||||
const chosen = (proposed ?? []).filter((card) => card.include && card.front.trim() && card.back.trim());
|
||||
|
||||
const patch = (id: string, change: Partial<ProposedCard>) =>
|
||||
setProposed((current) => current?.map((card) => (card.id === id ? { ...card, ...change } : card)) ?? null);
|
||||
|
||||
const generate = async () => {
|
||||
if (!deck || generating) return;
|
||||
setGenerating(true);
|
||||
setGenerateError(null);
|
||||
try {
|
||||
const { deck: generated } = await generateDeck({
|
||||
text: (deckHasNotes(deck) ? deck.sourceText : generatedDeckText(deck)).slice(0, 48_000),
|
||||
difficulty: deck.difficulty,
|
||||
count: Math.max(4, Math.min(8, gaps.length * 2)),
|
||||
focus: gapFocus(gaps),
|
||||
language: deck.language,
|
||||
mode: "more",
|
||||
existing: { title: deck.title, types: activityTypes(deck.activities), count: deck.activities.length },
|
||||
});
|
||||
const cards = generated.activities
|
||||
.map((activity, index) => {
|
||||
const pair = gapCardFrom(activity);
|
||||
return { id: `gap-${index}`, ...pair, include: true };
|
||||
})
|
||||
.filter((card) => card.front.length > 0 && card.back.length > 0);
|
||||
if (cards.length === 0) {
|
||||
setGenerateError(t("teach.generateFailed"));
|
||||
return;
|
||||
}
|
||||
setProposed(cards);
|
||||
} catch (caught) {
|
||||
setGenerateError(caught instanceof ApiError ? caught.message : t("teach.generateFailed"));
|
||||
} finally {
|
||||
setGenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const save = () => {
|
||||
if (!deck || chosen.length === 0) return;
|
||||
const activities = materialiseActivities(
|
||||
chosen.map((card) => ({
|
||||
type: "flashcard" as const,
|
||||
topic: topic.slice(0, 120) || undefined,
|
||||
front: card.front.trim().slice(0, 400),
|
||||
back: card.back.trim().slice(0, 1200),
|
||||
explanation: gaps[0]?.what.slice(0, 600) ?? t("teach.gapsToCards"),
|
||||
})),
|
||||
deck.difficulty,
|
||||
);
|
||||
upsertDeck({ ...deck, activities: [...deck.activities, ...activities], updatedAt: Date.now() });
|
||||
setProposed(null);
|
||||
setSavedCount(activities.length);
|
||||
showToast({ message: plural(activities.length, "teach.cardsSaved", { deck: deck.title }) });
|
||||
};
|
||||
|
||||
if (!open) {
|
||||
return (
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="touch-target"
|
||||
onClick={() => setOpen(true)}
|
||||
data-testid="teach-gap-cards"
|
||||
>
|
||||
<SparklesIcon className="size-4" /> {t("teach.gapsToCards")}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="card w-full space-y-3 p-4 sm:p-5" data-testid="teach-gap-panel">
|
||||
<h3 className="text-[13.5px] font-semibold text-[var(--text)]">{t("teach.gapsToCards")}</h3>
|
||||
|
||||
{savedCount !== null ? (
|
||||
<p className="text-[13px] leading-relaxed text-emerald-700 dark:text-emerald-300">
|
||||
{plural(savedCount, "teach.cardsSaved", { deck: deck?.title ?? "" })}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{!proposed ? (
|
||||
<>
|
||||
<label className="block">
|
||||
<span className="mb-1.5 block text-[12.5px] font-semibold text-[var(--text)]">{t("teach.gapDeck")}</span>
|
||||
<select
|
||||
value={deckId}
|
||||
onChange={(event) => setDeckId(event.target.value)}
|
||||
className="w-full rounded-[var(--radius-control)] border border-[var(--glass-border)] bg-[var(--surface-strong)] px-3 py-2.5 text-[16px] text-[var(--text)] outline-none focus:border-accent-400/60 sm:text-[13.5px]"
|
||||
>
|
||||
{decks.map((entry) => (
|
||||
<option key={entry.id} value={entry.id}>
|
||||
{entry.title}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
{generateError ? (
|
||||
<p role="alert" className="text-[12.5px] leading-relaxed text-rose-700 dark:text-rose-300">
|
||||
{generateError}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button size="sm" onClick={() => void generate()} loading={generating} disabled={generating || !deck}>
|
||||
{generating ? t("teach.generating") : t("teach.generate")}
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => setOpen(false)}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
<AiDisclaimer />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<h4 className="text-[12px] font-semibold uppercase tracking-wider text-[var(--text-subtle)]">{t("teach.reviewTitle")}</h4>
|
||||
<p className="text-[12px] leading-relaxed text-[var(--text-subtle)]">{t("teach.reviewHint")}</p>
|
||||
<ul className="space-y-2">
|
||||
{proposed.map((card) => (
|
||||
<li key={card.id} className="space-y-1.5 rounded-[var(--radius-control)] border border-[var(--glass-border)] bg-[var(--surface-strong)] p-2.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={card.include}
|
||||
onChange={(event) => patch(card.id, { include: event.target.checked })}
|
||||
aria-label={t("teach.reviewTitle")}
|
||||
className="size-4 accent-sky-500"
|
||||
/>
|
||||
<span className="flex-1 text-[11px] font-semibold uppercase tracking-wide text-[var(--text-subtle)]">
|
||||
{t("teach.cardFront")}
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
value={card.front}
|
||||
onChange={(event) => patch(card.id, { front: event.target.value })}
|
||||
aria-label={t("teach.cardFront")}
|
||||
maxLength={400}
|
||||
className="w-full rounded-[var(--radius-chip)] border border-[var(--glass-border)] bg-[var(--surface)] px-2.5 py-2 text-[16px] text-[var(--text)] outline-none focus:border-accent-400/60 sm:text-[13px]"
|
||||
/>
|
||||
<span className="block text-[11px] font-semibold uppercase tracking-wide text-[var(--text-subtle)]">{t("teach.cardBack")}</span>
|
||||
<textarea
|
||||
value={card.back}
|
||||
onChange={(event) => patch(card.id, { back: event.target.value })}
|
||||
aria-label={t("teach.cardBack")}
|
||||
rows={2}
|
||||
maxLength={1200}
|
||||
className="w-full resize-none rounded-[var(--radius-chip)] border border-[var(--glass-border)] bg-[var(--surface)] px-2.5 py-2 text-[16px] text-[var(--text)] outline-none focus:border-accent-400/60 sm:text-[13px]"
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button size="sm" onClick={save} disabled={chosen.length === 0} data-testid="teach-save-cards">
|
||||
{plural(chosen.length, "teach.saveCards")}
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => setProposed(null)}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
885
components/teach/TeachScreen.tsx
Normal file
885
components/teach/TeachScreen.tsx
Normal file
|
|
@ -0,0 +1,885 @@
|
|||
"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<View>("start");
|
||||
const [topic, setTopic] = useState("");
|
||||
const [difficulty, setDifficulty] = useState<TeachDifficulty>("friendly");
|
||||
const [scope, setScope] = useState<ChatScope>(() =>
|
||||
deckParam ? { kind: "decks", deckIds: [deckParam] } : { kind: "all" },
|
||||
);
|
||||
const [cards, setCards] = useState<TeachCardRef[]>([]);
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||
const [draft, setDraft] = useState("");
|
||||
const [conversationId, setConversationId] = useState<string | null>(null);
|
||||
const [summary, setSummary] = useState<TeachSummary | null>(null);
|
||||
const [summaryBusy, setSummaryBusy] = useState(false);
|
||||
const [summaryError, setSummaryError] = useState<string | null>(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<AbortController | null>(null);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const loadedRef = useRef<string | null>(null);
|
||||
const prefilledRef = useRef(false);
|
||||
const speechRef = useRef<SpeechInput | null>(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=<id> 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 (
|
||||
<div className="flex h-full min-h-0 w-full" style={{ paddingBottom: "var(--chat-keyboard, 0px)" }}>
|
||||
<div className="chat-column relative flex h-full min-h-0 min-w-0 flex-1 flex-col gap-[var(--layout-gutter)] p-[var(--layout-gutter)]">
|
||||
<header className="glass-panel chat-header z-30 flex h-14 shrink-0 items-center gap-2 px-2 sm:px-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => (view === "start" ? router.push("/") : setView("start"))}
|
||||
aria-label={t("teach.back")}
|
||||
title={t("teach.back")}
|
||||
className="grid size-11 shrink-0 place-items-center rounded-[var(--radius-control)] text-[var(--text-muted)] transition hover:bg-[var(--hover)] hover:text-[var(--text)] lg:hidden"
|
||||
>
|
||||
<ChevronLeftIcon className="size-5" />
|
||||
</button>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h1 className="truncate text-[15px] font-semibold text-[var(--text)]">
|
||||
{view === "session" ? topic : view === "summary" ? t("teach.summaryTitle") : t("teach.title")}
|
||||
</h1>
|
||||
<p className="truncate text-[11.5px] text-[var(--text-muted)]">
|
||||
{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")}
|
||||
</p>
|
||||
</div>
|
||||
{view === "session" ? (
|
||||
<Button size="sm" className="touch-target shrink-0" onClick={finish} disabled={busy || messages.length === 0} title={t("teach.finishHint")}>
|
||||
{t("teach.finish")}
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<ThemeToggle
|
||||
plain
|
||||
className="grid size-11 place-items-center rounded-[var(--radius-control)] border border-[var(--glass-border)] bg-[var(--surface-strong)]"
|
||||
/>
|
||||
{view === "summary" ? (
|
||||
<Button size="sm" variant="secondary" className="touch-target shrink-0" onClick={resetSession}>
|
||||
{t("teach.newSession")}
|
||||
</Button>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</header>
|
||||
|
||||
{view === "start" ? (
|
||||
<StartView
|
||||
topic={topic}
|
||||
setTopic={setTopic}
|
||||
difficulty={difficulty}
|
||||
setDifficulty={setDifficulty}
|
||||
scopeText={scopeText}
|
||||
scopeEmpty={sessionDecks.length === 0}
|
||||
scopeOpen={scopeOpen}
|
||||
modelOpen={modelOpen}
|
||||
modelLabel={modelLabel}
|
||||
onOpenScope={() => {
|
||||
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" ? (
|
||||
<>
|
||||
<div className="relative min-h-0 flex-1">
|
||||
<div ref={scrollRef} className="chat-scroll h-full overflow-y-auto overscroll-contain">
|
||||
<div
|
||||
className={cn(
|
||||
"mx-auto w-full max-w-6xl space-y-4 px-4 py-4 lg:px-10 lg:py-6",
|
||||
messages.length === 0 && "flex min-h-full flex-col justify-center",
|
||||
)}
|
||||
>
|
||||
{messages.length === 0 ? (
|
||||
<div className="chat-empty space-y-5 py-2">
|
||||
<div className="text-center">
|
||||
<span className="mx-auto mb-4 grid size-12 place-items-center rounded-[var(--radius-control)] border border-[var(--glass-border)] bg-[var(--surface-strong)] text-accent-600 dark:text-accent-200">
|
||||
<TeacherIcon className="size-6" />
|
||||
</span>
|
||||
<h2 className="text-[20px] font-semibold tracking-[-0.01em] text-[var(--text)]">{t("teach.emptyTitle")}</h2>
|
||||
<p className="mx-auto mt-1.5 max-w-sm text-[13.5px] leading-relaxed text-[var(--text-muted)]">
|
||||
{t("teach.emptyHint")}
|
||||
</p>
|
||||
<p className="mx-auto mt-2 max-w-sm text-[12px] text-[var(--text-subtle)]">
|
||||
{t("teach.studentLabel")}: {scopeText}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
messages.map((message, index) => (
|
||||
<motion.div
|
||||
key={message.id}
|
||||
initial={reduceMotion ? false : { opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={reduceMotion ? { duration: 0 } : springGentle}
|
||||
>
|
||||
<MessageRow
|
||||
message={message}
|
||||
streaming={busy && index === messages.length - 1 && message.role === "assistant"}
|
||||
isLastAssistant={index === messages.length - 1 && message.role === "assistant"}
|
||||
isLastUser={index === messages.length - 1 && message.role === "user"}
|
||||
busy={busy}
|
||||
onRegenerate={retryLast}
|
||||
onEdit={editLast}
|
||||
/>
|
||||
</motion.div>
|
||||
))
|
||||
)}
|
||||
<div className="h-2" aria-hidden />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<div
|
||||
role="alert"
|
||||
className="shrink-0 rounded-[var(--radius-control)] border border-rose-300/60 bg-rose-50/80 p-3 dark:border-rose-400/30 dark:bg-rose-500/10"
|
||||
>
|
||||
<p className="text-[13px] leading-relaxed text-rose-800 dark:text-rose-200">{error.message}</p>
|
||||
{messages.some((message) => message.role === "user") ? (
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2">
|
||||
<Button size="sm" variant="secondary" onClick={retryLast}>
|
||||
<RefreshIcon className="size-4" /> {t("chat.retry")}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="chat-composer relative shrink-0">
|
||||
<Composer
|
||||
value={draft}
|
||||
onChange={setDraft}
|
||||
onSend={() => 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 ? (
|
||||
<p role="status" className="mt-1.5 text-[11.5px] text-rose-600 dark:text-rose-300">
|
||||
{t("teach.listening")}
|
||||
</p>
|
||||
) : null}
|
||||
</Composer>
|
||||
<ModelPicker
|
||||
open={modelOpen}
|
||||
onClose={() => setModelOpen(false)}
|
||||
models={chatModel.models}
|
||||
value={chatModel.model}
|
||||
valueProvider={chatModel.provider}
|
||||
onChange={(next, nextProvider) => chatModel.choose(next, nextProvider)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{view === "summary" ? (
|
||||
<TeachRecap
|
||||
topic={topic}
|
||||
difficulty={difficulty}
|
||||
cards={cards}
|
||||
decks={decks}
|
||||
summary={summary}
|
||||
busy={summaryBusy}
|
||||
error={summaryError}
|
||||
onRetry={() => void runRecap(messages)}
|
||||
onBackToSession={() => setView("session")}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 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) => (
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={difficulty === value}
|
||||
onClick={() => setDifficulty(value)}
|
||||
className={cn(
|
||||
"flex w-full items-start gap-3 rounded-[var(--radius-control)] border px-3 py-3 text-left transition sm:flex-1",
|
||||
difficulty === value
|
||||
? "border-[var(--nav-active-border)] bg-[var(--nav-active-bg)]"
|
||||
: "border-[var(--glass-border)] bg-[var(--surface-strong)] hover:bg-[var(--hover)]",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn(
|
||||
"mt-0.5 grid size-4 shrink-0 place-items-center rounded-full border",
|
||||
difficulty === value ? "border-accent-500 bg-accent-500 text-white" : "border-[var(--hairline)]",
|
||||
)}
|
||||
>
|
||||
{difficulty === value ? <span className="size-1.5 rounded-full bg-white" /> : null}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block text-[13.5px] font-semibold text-[var(--text)]">{title}</span>
|
||||
<span className="mt-0.5 block text-[11.5px] leading-snug text-[var(--text-subtle)]">{note}</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="chat-scroll relative min-h-0 flex-1 overflow-y-auto overscroll-contain">
|
||||
<div className="mx-auto w-full max-w-3xl space-y-5 px-4 py-4 lg:px-0 lg:py-6">
|
||||
<div className="text-center">
|
||||
<span className="mx-auto mb-4 grid size-12 place-items-center rounded-[var(--radius-control)] border border-[var(--glass-border)] bg-[var(--surface-strong)] text-accent-600 dark:text-accent-200">
|
||||
<TeacherIcon className="size-6" />
|
||||
</span>
|
||||
<h2 className="text-[22px] font-semibold tracking-[-0.01em] text-[var(--text)]">{t("teach.title")}</h2>
|
||||
<p className="mx-auto mt-1.5 max-w-md text-[13.5px] leading-relaxed text-[var(--text-muted)]">{t("teach.intro")}</p>
|
||||
</div>
|
||||
|
||||
<section className="card space-y-4 p-4 sm:p-5">
|
||||
<div>
|
||||
<label htmlFor="teach-topic" className="mb-1.5 block text-[13px] font-semibold text-[var(--text)]">
|
||||
{t("teach.topicLabel")}
|
||||
</label>
|
||||
<input
|
||||
id="teach-topic"
|
||||
data-teach-topic
|
||||
value={topic}
|
||||
onChange={(event) => 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]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span className="mb-1.5 block text-[13px] font-semibold text-[var(--text)]">{t("teach.difficultyLabel")}</span>
|
||||
<div className="flex flex-col gap-2 sm:flex-row">
|
||||
{choice("friendly", t("teach.friendly"), t("teach.friendlyNote"))}
|
||||
{choice("skeptical", t("teach.skeptical"), t("teach.skepticalNote"))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span className="mb-1.5 block text-[13px] font-semibold text-[var(--text)]">{t("teach.studentLabel")}</span>
|
||||
<div className="relative flex flex-wrap items-center gap-2">
|
||||
<PickerChip icon={<TeacherIcon className="size-4 shrink-0" />} label={scopeText} open={scopeOpen} onClick={onOpenScope} />
|
||||
<PickerChip
|
||||
icon={<ChatIcon className="size-4 shrink-0" />}
|
||||
label={modelLabel}
|
||||
title={activeModel || modelLabel}
|
||||
open={modelOpen}
|
||||
onClick={onOpenModel}
|
||||
/>
|
||||
<ContextPicker
|
||||
open={scopeOpen}
|
||||
onClose={onCloseScope}
|
||||
scope={scope}
|
||||
onChange={onScopeChange}
|
||||
decks={decks}
|
||||
folders={folders}
|
||||
/>
|
||||
<ModelPicker
|
||||
open={modelOpen}
|
||||
onClose={onCloseModel}
|
||||
models={chatModels}
|
||||
value={activeModel}
|
||||
valueProvider={activeProvider}
|
||||
onChange={onChooseModel}
|
||||
/>
|
||||
</div>
|
||||
{scopeEmpty ? <p className="mt-2 text-[12px] leading-snug text-amber-700 dark:text-amber-300">{t("teach.noDecksHint")}</p> : null}
|
||||
</div>
|
||||
|
||||
<Button size="lg" className="w-full" disabled={!topic.trim() || !online} onClick={onStart} data-testid="teach-start">
|
||||
{t("teach.start")}
|
||||
</Button>
|
||||
<AiDisclaimer className="pt-1 text-center" />
|
||||
</section>
|
||||
|
||||
<section className="space-y-2">
|
||||
<h3 className="px-1 text-[11px] font-semibold uppercase tracking-wider text-[var(--text-subtle)]">{t("teach.recent")}</h3>
|
||||
{sessions.length === 0 ? (
|
||||
<p className="px-1 text-[12.5px] text-[var(--text-subtle)]">{t("teach.noSessions")}</p>
|
||||
) : (
|
||||
<ul className="space-y-1.5">
|
||||
{sessions.slice(0, 8).map((session) => (
|
||||
<li key={session.id}>
|
||||
<div className="flex items-center gap-2 rounded-[var(--radius-control)] border border-[var(--glass-border)] bg-[var(--surface-strong)] px-3 py-2.5">
|
||||
<button type="button" onClick={() => onOpenSession(session)} className="min-w-0 flex-1 text-left">
|
||||
<span className="block truncate text-[13.5px] font-semibold text-[var(--text)]">
|
||||
{session.title || t("teach.untitled")}
|
||||
</span>
|
||||
<span className="mt-0.5 block truncate text-[11.5px] text-[var(--text-subtle)]">
|
||||
{formatDate(session.updatedAt)} ·{" "}
|
||||
{session.teach?.summary ? t("teach.recap") : t("teach.inProgress")} ·{" "}
|
||||
{session.teach?.difficulty === "skeptical" ? t("teach.skeptical") : t("teach.friendly")}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRemoveSession(session)}
|
||||
aria-label={t("teach.deleteSession")}
|
||||
title={t("teach.deleteSession")}
|
||||
className="grid size-8 shrink-0 place-items-center rounded-full text-[var(--text-subtle)] transition hover:bg-[var(--hover)] hover:text-rose-600 dark:hover:text-rose-300"
|
||||
>
|
||||
<XIcon className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue