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
25
README.md
25
README.md
|
|
@ -1492,6 +1492,31 @@ Things I chose while building, with the reasoning:
|
|||
into view as you type. No strings, so no i18n; the additive change sits on top of the merged
|
||||
scene-heat rework unchanged.
|
||||
|
||||
109. **Teach it back: the AI stops being the tutor and becomes the student.** The mode where the user
|
||||
explains a topic and an AI classmate probes the gaps — never a grader, never lecturing.
|
||||
`/teach` (a sidebar row next to Exam mode, plus *Teach it back* in every deck's actions)
|
||||
starts from a deck — which prefills the topic and checks against that deck's own cards — or
|
||||
from a typed topic with the same context and model chips Chat uses. The student is a prompt
|
||||
variant of the chat route (`mode: "teach"`), so model resolution, allowlists, rate limits and
|
||||
the streaming client are literally the same code; the personality lives in `lib/prompts.ts`
|
||||
(`TEACH_*`): friendly or skeptical, one short reaction plus exactly one follow-up question,
|
||||
never "correct"/"wrong", never its own facts, and it says so instead of guessing when the
|
||||
notes cover nothing. It receives the deck's cards as a numbered checklist ("[3] Why …? → …")
|
||||
and is told to ask about the ideas that were skipped. After `TEACH_MAX_EXCHANGES` (10, one
|
||||
constant) replies — or whenever Finish is pressed — a second `teach_summary` call turns the
|
||||
transcript into the recap JSON (`well` / `gaps` with card references / `next`), with one
|
||||
repair retry, and each gap links back to the exact card it refers to. The recap's two
|
||||
follow-ups: *Add missed cards to review* puts those activities on the SRS schedule due now,
|
||||
and *Turn gaps into cards* generates one flashcard per gap from the chosen deck's own notes
|
||||
and shows them in an editable review list (checkbox, front, back) before anything is saved.
|
||||
Sessions are chat conversations with a `mode: "teach"` marker plus the session record, so they
|
||||
inherit the IndexedDB history, backup and encrypted sync for free while staying out of the
|
||||
Chat screens; the recent list on the start screen reopens a recap or continues an unfinished
|
||||
session. The screen reuses MessageRow, the Composer (now able to drop its attach/scope chips
|
||||
and take a mic), Markdown + KaTeX, AiDisclaimer and the pickers; dictation uses the Web Speech
|
||||
API's recognition side, and the mic simply does not render where the browser cannot listen.
|
||||
Verified by the teach suite (44 checks).
|
||||
|
||||
108. **The clock that learned the engine's pace.** "The tts dispatcher thing and the text highlight is
|
||||
completely out of sync on desktop" — Firefox + speech-dispatcher (like iOS) reports **no
|
||||
`onboundary` events**, so the highlight rides the estimated clock, and that estimate drifts
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import { rejectCrossOrigin, rejectOversizedBody, requestLocale } from "@/lib/htt
|
|||
import { clientKey, rateLimit, rateLimitResponse } from "@/lib/rate-limit";
|
||||
import { acquireModelGate } from "@/lib/model-guard";
|
||||
import { translate } from "@/lib/i18n-dict";
|
||||
import { buildChatSystemPrompt } from "@/lib/prompts";
|
||||
import { buildChatSystemPrompt, buildTeachSummarySystemPrompt, buildTeachSystemPrompt } from "@/lib/prompts";
|
||||
import { estimateTokens, trimHistory } from "@/lib/chat-trim";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
|
@ -43,6 +43,17 @@ const requestSchema = z.object({
|
|||
)
|
||||
.max(40)
|
||||
.optional(),
|
||||
/** Teach it back: "teach" is one student turn, "teach_summary" the recap. */
|
||||
mode: z.enum(["teach", "teach_summary"]).optional(),
|
||||
teach: z
|
||||
.object({
|
||||
topic: z.string().min(1).max(200),
|
||||
difficulty: z.enum(["friendly", "skeptical"]),
|
||||
exchange: z.number().int().min(0).max(60),
|
||||
limit: z.number().int().min(1).max(60),
|
||||
cards: z.array(z.string().max(1000)).max(80),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
|
||||
type StreamEvent =
|
||||
|
|
@ -97,7 +108,13 @@ export async function POST(request: Request) {
|
|||
);
|
||||
}
|
||||
|
||||
const { messages, context } = parsed.data;
|
||||
const { messages, context, mode, teach } = parsed.data;
|
||||
if (mode && !teach) {
|
||||
return Response.json(
|
||||
{ error: translate(locale, "server.invalidRequest", { detail: "teach mode needs the teach payload" }) },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// The model must be one the server actually offers — the configured
|
||||
// favourites, or anything the user's Ollama lists. The picker shows exactly
|
||||
|
|
@ -142,13 +159,29 @@ export async function POST(request: Request) {
|
|||
}
|
||||
const history = trimHistory(messages);
|
||||
|
||||
const system = buildChatSystemPrompt({
|
||||
context: keptContext.map((chunk) => ({
|
||||
const notesContext = keptContext.map((chunk) => ({
|
||||
deckTitle: chunk.deckTitle,
|
||||
section: chunk.section,
|
||||
text: chunk.text,
|
||||
})),
|
||||
});
|
||||
}));
|
||||
const system =
|
||||
mode === "teach" && teach
|
||||
? buildTeachSystemPrompt({
|
||||
topic: teach.topic,
|
||||
difficulty: teach.difficulty,
|
||||
cards: teach.cards,
|
||||
context: notesContext,
|
||||
exchange: teach.exchange,
|
||||
limit: teach.limit,
|
||||
})
|
||||
: mode === "teach_summary" && teach
|
||||
? buildTeachSummarySystemPrompt({
|
||||
topic: teach.topic,
|
||||
difficulty: teach.difficulty,
|
||||
cards: teach.cards,
|
||||
context: notesContext,
|
||||
})
|
||||
: buildChatSystemPrompt({ context: notesContext });
|
||||
|
||||
// Concurrency + the account-wide model budget (lib/model-guard), taken
|
||||
// after every cheap rejection so a 400 can never leak a slot.
|
||||
|
|
|
|||
32
app/teach/page.tsx
Normal file
32
app/teach/page.tsx
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import { Suspense } from "react";
|
||||
|
||||
import { ApiKeyGate } from "@/components/ApiKeyGate";
|
||||
import { AppHeader } from "@/components/AppHeader";
|
||||
import { Sidebar, SidebarOffset } from "@/components/Sidebar";
|
||||
import { TeachScreen } from "@/components/teach/TeachScreen";
|
||||
|
||||
export const metadata = {
|
||||
title: "Teach it back",
|
||||
};
|
||||
|
||||
/**
|
||||
* Teach it back. Phones get a full-screen session (the tab bar steps aside,
|
||||
* see TabBar); desktop keeps the sidebar. The hidden AppHeader is only there
|
||||
* to mount the settings dialog the sidebar opens.
|
||||
*/
|
||||
export default function TeachPage() {
|
||||
return (
|
||||
<ApiKeyGate>
|
||||
<div className="flex min-h-dvh">
|
||||
<Sidebar />
|
||||
<SidebarOffset />
|
||||
<main className="flex h-dvh min-h-dvh min-w-0 flex-1 flex-col">
|
||||
<AppHeader className="hidden" />
|
||||
<Suspense fallback={null}>
|
||||
<TeachScreen />
|
||||
</Suspense>
|
||||
</main>
|
||||
</div>
|
||||
</ApiKeyGate>
|
||||
);
|
||||
}
|
||||
|
|
@ -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,7 +112,9 @@ export function ChatScreen() {
|
|||
}, []);
|
||||
|
||||
const loadedRef = useRef<string | null>(null);
|
||||
const loadConversation = useCallback((found: ChatConversation) => {
|
||||
const { restore: restoreModel } = chatModel;
|
||||
const loadConversation = useCallback(
|
||||
(found: ChatConversation) => {
|
||||
setConversationId(found.id);
|
||||
setTitle(found.title);
|
||||
setMessages(conversationMessages(found));
|
||||
|
|
@ -132,11 +123,12 @@ export function ChatScreen() {
|
|||
setAttachments([]);
|
||||
setDraft("");
|
||||
setError(null);
|
||||
if (found.model) setModel(found.model);
|
||||
if (found.provider) setProvider(found.provider);
|
||||
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,6 +114,8 @@ export function Composer({
|
|||
) : null}
|
||||
|
||||
<div className="flex items-end gap-2">
|
||||
{onAttachFile ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t("chat.attach")}
|
||||
|
|
@ -118,6 +139,27 @@ export function Composer({
|
|||
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}
|
||||
{scopeLabel && onOpenScope ? (
|
||||
<PickerChip
|
||||
icon={<LayersIcon className="size-4 shrink-0" />}
|
||||
label={scopeLabel}
|
||||
open={scopeOpen ?? false}
|
||||
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}
|
||||
/>
|
||||
) : 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(
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -24,6 +24,17 @@ export type StreamChatInput = {
|
|||
provider?: string;
|
||||
messages: { role: "user" | "assistant"; content: string }[];
|
||||
context: (ChatSource & { text: string })[];
|
||||
/** "teach" runs a Teach it back turn; "teach_summary" asks for the recap JSON. */
|
||||
mode?: "teach" | "teach_summary";
|
||||
teach?: {
|
||||
topic: string;
|
||||
difficulty: "friendly" | "skeptical";
|
||||
/** Exchanges answered so far, and how many the session plans. */
|
||||
exchange: number;
|
||||
limit: number;
|
||||
/** "[n] question → answer" checklist lines (lib/teach.ts). */
|
||||
cards: string[];
|
||||
};
|
||||
signal?: AbortSignal;
|
||||
onToken: (text: string) => void;
|
||||
onThinking: (text: string) => void;
|
||||
|
|
@ -40,6 +51,8 @@ export async function streamChat(input: StreamChatInput): Promise<void> {
|
|||
model: input.model || undefined,
|
||||
messages: input.messages,
|
||||
context: input.context,
|
||||
mode: input.mode,
|
||||
teach: input.teach,
|
||||
}),
|
||||
signal: input.signal,
|
||||
});
|
||||
|
|
|
|||
96
lib/chat-models.ts
Normal file
96
lib/chat-models.ts
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* The chat model list and the remembered choice, shared by the Chat screen and
|
||||
* Teach it back so both offer exactly the same models and remember the same
|
||||
* pick. The server's health check lists the Ollama models; every enabled cloud
|
||||
* provider contributes its own (live listing first, built-in seeds after).
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { readStoredChatModel, readStoredChatProvider, storeChatModel, storeChatProvider, type ChatModelChoice } from "./chat";
|
||||
import { PROVIDERS } from "./providers";
|
||||
import { modelsFor, useProviders } from "./providers-client";
|
||||
import { useHealth } from "./use-health";
|
||||
|
||||
export function useChatModels(): {
|
||||
/** Everything the picker may offer. */
|
||||
models: ChatModelChoice[];
|
||||
/** The model a request should use (falls back to the first available one). */
|
||||
model: string;
|
||||
/** The provider that owns `model` ("ollama" when none). */
|
||||
provider: string;
|
||||
/** Picks a model and remembers it across visits. */
|
||||
choose: (model: string, provider?: string) => void;
|
||||
/** Restores a saved conversation's choice without remembering it. */
|
||||
restore: (model?: string, provider?: string) => void;
|
||||
} {
|
||||
const { health } = useHealth();
|
||||
const providerState = useProviders();
|
||||
const [model, setModel] = useState("");
|
||||
const [provider, setProvider] = useState("ollama");
|
||||
|
||||
useEffect(() => {
|
||||
setModel(readStoredChatModel() ?? "");
|
||||
setProvider(readStoredChatProvider());
|
||||
}, []);
|
||||
|
||||
const models = useMemo<ChatModelChoice[]>(() => {
|
||||
const fromHealth = health?.chatModels ?? [];
|
||||
const list =
|
||||
fromHealth.length > 0
|
||||
? fromHealth
|
||||
: [
|
||||
{ name: health?.text?.configured ?? "", badge: "text" as const, available: health?.text?.available ?? null },
|
||||
{ name: health?.vision?.configured ?? "", badge: "vision" as const, available: health?.vision?.available ?? null },
|
||||
];
|
||||
const ollama = list.filter((entry) => entry.name);
|
||||
const cloud: ChatModelChoice[] = [];
|
||||
for (const info of PROVIDERS) {
|
||||
if (!providerState.keys[info.id]) continue;
|
||||
const models = modelsFor(providerState, info.id).slice(0, 400);
|
||||
if (models.length === 0) continue;
|
||||
// Only a live /models listing proves the key can use them; the built-in
|
||||
// seeds stay badge-less until the first successful test.
|
||||
const listed = (providerState.models[info.id]?.length ?? 0) > 0;
|
||||
for (const name of models) {
|
||||
cloud.push({ name, badge: "text", available: listed ? true : null, provider: info.id });
|
||||
}
|
||||
}
|
||||
return [...ollama, ...cloud];
|
||||
}, [health, providerState]);
|
||||
|
||||
const activeProvider = provider !== "ollama" && !providerState.keys[provider] ? "ollama" : provider;
|
||||
|
||||
const activeModel = useMemo(() => {
|
||||
if (model && models.some((entry) => entry.name === model && (entry.provider ?? "ollama") === activeProvider)) {
|
||||
return model;
|
||||
}
|
||||
// The server puts the default first, but a different server may not have
|
||||
// it — fall back to the first model that is actually available.
|
||||
const firstAvailable = models.find(
|
||||
(entry) => (entry.provider ?? "ollama") === activeProvider && entry.available !== false,
|
||||
);
|
||||
return (
|
||||
firstAvailable ??
|
||||
models.find((entry) => (entry.provider ?? "ollama") === activeProvider) ??
|
||||
models[0]
|
||||
)?.name ?? model;
|
||||
}, [model, models, activeProvider]);
|
||||
|
||||
const choose = useCallback((next: string, nextProvider?: string) => {
|
||||
const chosenProvider = nextProvider ?? "ollama";
|
||||
setProvider(chosenProvider);
|
||||
storeChatProvider(chosenProvider);
|
||||
setModel(next);
|
||||
storeChatModel(next);
|
||||
}, []);
|
||||
|
||||
const restore = useCallback((nextModel?: string, nextProvider?: string) => {
|
||||
if (nextModel) setModel(nextModel);
|
||||
if (nextProvider) setProvider(nextProvider);
|
||||
}, []);
|
||||
|
||||
return { models, model: activeModel, provider: activeProvider, choose, restore };
|
||||
}
|
||||
|
|
@ -226,6 +226,53 @@ export function recordAnswer(input: AnswerInput, at = Date.now()): void {
|
|||
}
|
||||
|
||||
/** Used by backup import and sync pulls. */
|
||||
/**
|
||||
* Puts specific cards on the review schedule, due right now — Teach it back's
|
||||
* "add the missed cards to review". A card that was already scheduled keeps
|
||||
* its ease and lapse count; only the due date is pulled forward. Returns how
|
||||
* many cards were scheduled.
|
||||
*/
|
||||
export function scheduleForReview(deckId: string, activityIds: string[], at = Date.now()): number {
|
||||
const changed: SrsRecord[] = [];
|
||||
for (const activityId of activityIds) {
|
||||
const key = `${deckId}:${activityId}`;
|
||||
const previous = schedules.get(key);
|
||||
changed.push({
|
||||
key,
|
||||
deckId,
|
||||
activityId,
|
||||
ease: previous?.ease ?? 2.5,
|
||||
reps: previous?.reps ?? 0,
|
||||
intervalDays: 0,
|
||||
lapses: previous?.lapses ?? 0,
|
||||
dueAt: at,
|
||||
lastAt: at,
|
||||
lastCorrect: previous?.lastCorrect ?? false,
|
||||
});
|
||||
}
|
||||
if (changed.length === 0) return 0;
|
||||
|
||||
schedules = new Map(schedules);
|
||||
for (const schedule of changed) schedules.set(schedule.key, schedule);
|
||||
notify();
|
||||
markSyncDirty();
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
await ensureLearningHydrated();
|
||||
await withDb("readwrite", (db) => {
|
||||
const transaction = db.transaction(SRS, "readwrite");
|
||||
const store = transaction.objectStore(SRS);
|
||||
for (const schedule of changed) store.put(schedule);
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn("[freezmo] could not schedule the review cards", error);
|
||||
}
|
||||
})();
|
||||
|
||||
return changed.length;
|
||||
}
|
||||
|
||||
export async function replaceLearning(nextAnswers: AnswerRecord[], nextSrs: SrsRecord[]): Promise<void> {
|
||||
const sorted = [...nextAnswers].sort((a, b) => b.at - a.at).slice(0, MAX_ANSWERS);
|
||||
answers = sorted;
|
||||
|
|
|
|||
134
lib/i18n-dict.ts
134
lib/i18n-dict.ts
|
|
@ -1107,6 +1107,73 @@ const en: Record<string, string> = {
|
|||
"chat.savedToNotes": "Saved to {deck}'s notes",
|
||||
"chat.savedToNotesTrimmed": "Saved to {deck}'s notes (trimmed to fit)",
|
||||
"chat.savedFromChat": "Saved from chat",
|
||||
"chat.voiceStart": "Dictate",
|
||||
"chat.voiceStop": "Stop dictating",
|
||||
"teach.nav": "Teach it back",
|
||||
"teach.title": "Teach it back",
|
||||
"teach.subtitle": "You explain. The AI plays your student.",
|
||||
"teach.intro": "Explain the topic in your own words and the student asks short follow-up questions. Where it keeps digging, that is your gap.",
|
||||
"teach.topicLabel": "What will you explain?",
|
||||
"teach.topicPlaceholder": "e.g. Why the sky is blue",
|
||||
"teach.studentLabel": "What may the student know?",
|
||||
"teach.difficultyLabel": "Pick a student",
|
||||
"teach.friendly": "Friendly student",
|
||||
"teach.friendlyNote": "Patient questions, wants it to click.",
|
||||
"teach.skeptical": "Skeptical student",
|
||||
"teach.skepticalNote": "Asks why, looks for the catch and the edges.",
|
||||
"teach.start": "Start teaching",
|
||||
"teach.recent": "Recent sessions",
|
||||
"teach.noSessions": "Nothing here yet — finished sessions and their recaps appear here.",
|
||||
"teach.inProgress": "In progress",
|
||||
"teach.recap": "Recap",
|
||||
"teach.newSession": "New session",
|
||||
"teach.open": "Open",
|
||||
"teach.untitled": "Untitled session",
|
||||
"teach.deleteSession": "Delete session",
|
||||
"teach.deleted": "Session deleted",
|
||||
"teach.back": "Back",
|
||||
"teach.finish": "Finish",
|
||||
"teach.finishHint": "End the session and get your recap.",
|
||||
"teach.exchangeCount": "{n}/{max}",
|
||||
"teach.youExplain": "You explain",
|
||||
"teach.emptyTitle": "You are the teacher now",
|
||||
"teach.emptyHint": "Explain the topic the way you would tell a classmate. The student asks one short follow-up at a time — no grading, no lectures.",
|
||||
"teach.placeholder": "Explain it in your own words…",
|
||||
"teach.voiceStart": "Dictate your explanation",
|
||||
"teach.voiceStop": "Stop dictating",
|
||||
"teach.listening": "Listening…",
|
||||
"teach.limitReached": "Good place to wrap up — here is your recap.",
|
||||
"teach.summaryTitle": "Recap",
|
||||
"teach.summaryBusy": "Writing your recap…",
|
||||
"teach.summaryFailed": "The recap could not be written. Try again.",
|
||||
"teach.retry": "Write it again",
|
||||
"teach.well": "Explained well",
|
||||
"teach.wellEmpty": "Nothing stood out yet.",
|
||||
"teach.gaps": "Missed or unclear",
|
||||
"teach.gapsEmpty": "Nothing was missed.",
|
||||
"teach.next": "Next steps",
|
||||
"teach.cardLinkTitle": "Open in {deck}",
|
||||
"teach.addToReview": "Add missed cards to review",
|
||||
"teach.reviewAdded.one": "{n} card added to review",
|
||||
"teach.reviewAdded.few": "{n} cards added to review",
|
||||
"teach.reviewAdded.many": "{n} cards added to review",
|
||||
"teach.gapsToCards": "Turn gaps into cards",
|
||||
"teach.gapDeck": "Add the cards to",
|
||||
"teach.generate": "Create the cards",
|
||||
"teach.generating": "Creating the cards…",
|
||||
"teach.reviewTitle": "Review the cards",
|
||||
"teach.reviewHint": "Edit anything before saving; uncheck what you do not want.",
|
||||
"teach.cardFront": "Front",
|
||||
"teach.cardBack": "Back",
|
||||
"teach.saveCards.one": "Save {n} card",
|
||||
"teach.saveCards.few": "Save {n} cards",
|
||||
"teach.saveCards.many": "Save {n} cards",
|
||||
"teach.cardsSaved.one": "{n} card added to {deck}",
|
||||
"teach.cardsSaved.few": "{n} cards added to {deck}",
|
||||
"teach.cardsSaved.many": "{n} cards added to {deck}",
|
||||
"teach.generateFailed": "The cards could not be created.",
|
||||
"teach.noGapDeck": "Pick a deck with notes for the cards.",
|
||||
"teach.noDecksHint": "No decks are selected — the student has nothing to check against.",
|
||||
"legal.updated": "Last updated {when}",
|
||||
"legal.privacy": "Privacy policy",
|
||||
"legal.terms": "Terms of service",
|
||||
|
|
@ -2197,6 +2264,73 @@ const cs: Record<string, string> = {
|
|||
"chat.savedToNotes": "Uloženo do poznámek sady {deck}",
|
||||
"chat.savedToNotesTrimmed": "Uloženo do poznámek sady {deck} (zkráceno, aby se vešlo)",
|
||||
"chat.savedFromChat": "Uloženo z chatu",
|
||||
"chat.voiceStart": "Diktovat",
|
||||
"chat.voiceStop": "Zastavit diktování",
|
||||
"teach.nav": "Vysvětli to",
|
||||
"teach.title": "Vysvětli to",
|
||||
"teach.subtitle": "Ty vysvětluješ. AI hraje studenta.",
|
||||
"teach.intro": "Vysvětli téma vlastními slovy a student se bude krátce doptávat. Kde bude kopat hlouběji, tam máš mezeru.",
|
||||
"teach.topicLabel": "Co budeš vysvětlovat?",
|
||||
"teach.topicPlaceholder": "např. Proč je nebe modré",
|
||||
"teach.studentLabel": "Co smí student znát?",
|
||||
"teach.difficultyLabel": "Vyber si studenta",
|
||||
"teach.friendly": "Přátelský student",
|
||||
"teach.friendlyNote": "Trpělivé otázky, chce to pochopit.",
|
||||
"teach.skeptical": "Nedůvěřivý student",
|
||||
"teach.skepticalNote": "Ptá se proč, hledá háček a okrajové případy.",
|
||||
"teach.start": "Začít učit",
|
||||
"teach.recent": "Nedávné výuky",
|
||||
"teach.noSessions": "Zatím tu nic není — dokončené výuky a jejich přehledy se objeví tady.",
|
||||
"teach.inProgress": "Probíhá",
|
||||
"teach.recap": "Přehled",
|
||||
"teach.newSession": "Nová výuka",
|
||||
"teach.open": "Otevřít",
|
||||
"teach.untitled": "Výuka bez názvu",
|
||||
"teach.deleteSession": "Smazat výuku",
|
||||
"teach.deleted": "Výuka smazána",
|
||||
"teach.back": "Zpět",
|
||||
"teach.finish": "Dokončit",
|
||||
"teach.finishHint": "Ukončit výuku a zobrazit přehled.",
|
||||
"teach.exchangeCount": "{n}/{max}",
|
||||
"teach.youExplain": "Ty vysvětluješ",
|
||||
"teach.emptyTitle": "Teď učíš ty",
|
||||
"teach.emptyHint": "Vysvětli téma, jako bys ho vyprávěl spolužákovi. Student se vždy zeptá na jednu krátkou věc — žádné známkování, žádné přednášky.",
|
||||
"teach.placeholder": "Vysvětli to vlastními slovy…",
|
||||
"teach.voiceStart": "Nadiktovat vysvětlení",
|
||||
"teach.voiceStop": "Zastavit diktování",
|
||||
"teach.listening": "Poslouchám…",
|
||||
"teach.limitReached": "Tady je dobré místo skončit — tady je přehled.",
|
||||
"teach.summaryTitle": "Přehled",
|
||||
"teach.summaryBusy": "Píšu přehled…",
|
||||
"teach.summaryFailed": "Přehled se nepovedlo napsat. Zkus to znovu.",
|
||||
"teach.retry": "Napsat znovu",
|
||||
"teach.well": "Dobře vysvětlené",
|
||||
"teach.wellEmpty": "Zatím nic nevyniklo.",
|
||||
"teach.gaps": "Chybělo nebo bylo nejasné",
|
||||
"teach.gapsEmpty": "Nic nechybělo.",
|
||||
"teach.next": "Další kroky",
|
||||
"teach.cardLinkTitle": "Otevřít v sadě {deck}",
|
||||
"teach.addToReview": "Přidat vynechané kartičky k opakování",
|
||||
"teach.reviewAdded.one": "{n} kartička přidána k opakování",
|
||||
"teach.reviewAdded.few": "{n} kartičky přidány k opakování",
|
||||
"teach.reviewAdded.many": "{n} kartiček přidáno k opakování",
|
||||
"teach.gapsToCards": "Udělat z mezer kartičky",
|
||||
"teach.gapDeck": "Přidat kartičky do",
|
||||
"teach.generate": "Vytvořit kartičky",
|
||||
"teach.generating": "Vytvářím kartičky…",
|
||||
"teach.reviewTitle": "Zkontroluj kartičky",
|
||||
"teach.reviewHint": "Před uložením cokoli uprav; nechtěné odškrtni.",
|
||||
"teach.cardFront": "Přední strana",
|
||||
"teach.cardBack": "Zadní strana",
|
||||
"teach.saveCards.one": "Uložit {n} kartičku",
|
||||
"teach.saveCards.few": "Uložit {n} kartičky",
|
||||
"teach.saveCards.many": "Uložit {n} kartiček",
|
||||
"teach.cardsSaved.one": "{n} kartička přidána do sady {deck}",
|
||||
"teach.cardsSaved.few": "{n} kartičky přidány do sady {deck}",
|
||||
"teach.cardsSaved.many": "{n} kartiček přidáno do sady {deck}",
|
||||
"teach.generateFailed": "Kartičky se nepovedlo vytvořit.",
|
||||
"teach.noGapDeck": "Vyber sadu s poznámkami pro kartičky.",
|
||||
"teach.noDecksHint": "Nejsou vybrané žádné sady — student nemá proti čemu ověřovat.",
|
||||
"legal.updated": "Naposledy upraveno {when}",
|
||||
"legal.privacy": "Zásady soukromí",
|
||||
"legal.terms": "Podmínky použití",
|
||||
|
|
|
|||
139
lib/prompts.ts
139
lib/prompts.ts
|
|
@ -469,6 +469,145 @@ export function buildChatSystemPrompt(input: {
|
|||
return [CHAT_SYSTEM_PROMPT, "", "The student's notes (primary source):", "", blocks].join("\n");
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Teach it back */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/**
|
||||
* The AI plays a curious classmate — never a teacher. Every student turn is a
|
||||
* short reaction plus exactly one follow-up question, and everything it knows
|
||||
* comes from the cards and notes attached below. This is the personality of
|
||||
* the whole mode: tune it here, not in the routes or the UI.
|
||||
*/
|
||||
export const TEACH_STUDENT_RULES = `ROLE (critical):
|
||||
- You are the STUDENT: a classmate of the person explaining, who is the teacher. You know nothing about the topic yet.
|
||||
- Every reply is one short natural reaction plus exactly ONE question. Never two questions, never a list, never an explanation.
|
||||
- Ask about one concrete thing at a time, the way a person actually asks: "wait, why does that step work?", "what happens if the value is negative?", "how do you know it's that one?". Keep the whole reply under ~40 words.
|
||||
- NEVER judge the explanation. No "correct", "right", "wrong", "exactly", "good", "not quite", no scores, no feedback words of any kind. If something seems off, sound genuinely unsure and ask ("hmm, I thought the other one did that — can you walk me through why?").
|
||||
- NEVER lecture and never explain the topic yourself. You may briefly say what you took from their words ("so the pressure drops first —") and then ask.
|
||||
- NEVER state facts of your own. The material below is everything you are allowed to know. If the teacher says something it does not cover, say plainly that you have nothing about it in the notes and ask them to say more — never guess, never agree with a fact you cannot check.
|
||||
- Write in the language the teacher writes in. Never switch languages.`;
|
||||
|
||||
export function buildTeachSystemPrompt(input: {
|
||||
topic: string;
|
||||
difficulty: "friendly" | "skeptical";
|
||||
/** "[n] prompt → answer" checklist lines (see lib/teach.ts). */
|
||||
cards: string[];
|
||||
context: { deckTitle: string; section: string; text: string }[];
|
||||
/** Exchanges the teacher has already answered, and how many are planned. */
|
||||
exchange: number;
|
||||
limit: number;
|
||||
}): string {
|
||||
const tone =
|
||||
input.difficulty === "friendly"
|
||||
? `YOUR CHARACTER: the friendly student. You like the topic, you are on the teacher's side and you want it to click; your questions are simple and patient. You are still not a pushover — if something is missing, you ask about it.`
|
||||
: `YOUR CHARACTER: the skeptical student. You are not hostile, but you are not convinced easily: you ask why, how they know, what happens at the edges and in special cases, and whether it still works when something changes. You want the explanation to hold up.`;
|
||||
const budget =
|
||||
input.limit > 0
|
||||
? `There are about ${input.limit} exchanges planned and ${input.exchange} answered so far. Spend the remaining ones on ideas from the cards that have not been covered yet, most important first.`
|
||||
: "";
|
||||
|
||||
const lines = [
|
||||
`You are rehearsing with a classmate who is teaching you about: ${input.topic}`,
|
||||
"",
|
||||
TEACH_STUDENT_RULES,
|
||||
"",
|
||||
tone,
|
||||
];
|
||||
if (budget) lines.push(budget);
|
||||
|
||||
lines.push("");
|
||||
if (input.cards.length > 0) {
|
||||
lines.push("THE CARDS the teacher is expected to be able to explain ([n] is their reference number):");
|
||||
lines.push(...input.cards);
|
||||
} else {
|
||||
lines.push(
|
||||
"NO CARDS were attached: you cannot check anything against the notes. Ask follow-up questions about the explanation itself, and say so when you cannot verify something.",
|
||||
);
|
||||
}
|
||||
|
||||
if (input.context.length > 0) {
|
||||
lines.push(
|
||||
"",
|
||||
"THE TEACHER'S NOTES (the only material the two of you share):",
|
||||
"",
|
||||
input.context
|
||||
.map((chunk) => `### ${chunk.deckTitle} — ${chunk.section}\n${chunk.text}`)
|
||||
.join("\n\n"),
|
||||
);
|
||||
} else {
|
||||
lines.push("", "NO NOTES were attached. Nothing about the topic may come from you.");
|
||||
}
|
||||
|
||||
lines.push(
|
||||
"",
|
||||
"Now answer the teacher's latest message as this student. Plain conversational text only (no markdown, no lists): one short reaction and one question.",
|
||||
);
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
/** The JSON shape the recap must follow — referenced by prompt and parser. */
|
||||
export const TEACH_SUMMARY_SHAPE = `{
|
||||
"well": ["what the teacher explained clearly, concretely — 1 to 4 bullets"],
|
||||
"gaps": [{ "what": "what was missed, vague or left out — up to 6 bullets", "card": 3 }],
|
||||
"next": ["one or two realistic next steps"]
|
||||
}`;
|
||||
|
||||
export const TEACH_SUMMARY_RULES = `You are still the same student, but the practice is over and you now write the app's recap of it. Nobody talks to you here; you describe the session.
|
||||
Ground rules:
|
||||
- Be concrete and kind, never a grader: no scores, no "correct"/"wrong", no praise words. "Vague", "not covered" and "missed" are fine.
|
||||
- The material below is the only truth. Never invent facts and never add outside knowledge.
|
||||
- Write in the language of the session (the language the teacher used).`;
|
||||
|
||||
export function buildTeachSummarySystemPrompt(input: {
|
||||
topic: string;
|
||||
difficulty: "friendly" | "skeptical";
|
||||
cards: string[];
|
||||
context: { deckTitle: string; section: string; text: string }[];
|
||||
}): string {
|
||||
const lines = [
|
||||
input.difficulty === "friendly"
|
||||
? `You (the friendly student) were just taught about: ${input.topic}`
|
||||
: `You (the skeptical student) were just taught about: ${input.topic}`,
|
||||
"",
|
||||
TEACH_SUMMARY_RULES,
|
||||
"",
|
||||
"Return JSON only, exactly this shape:",
|
||||
TEACH_SUMMARY_SHAPE,
|
||||
"",
|
||||
"Field rules:",
|
||||
`- "well": name what the teacher actually explained well, concretely ("why violet scatters most"), never general praise.`,
|
||||
`- "gaps": ideas from the cards that were never mentioned, ideas that stayed vague, and things their notes do not cover at all (say that plainly). "card" is the [n] reference of the card this gap belongs to, or null when no card covers it.`,
|
||||
`- "next": 1-2 actions the teacher can really do next, aimed at the gaps.`,
|
||||
];
|
||||
|
||||
if (input.cards.length > 0) {
|
||||
lines.push("", "THE CARDS ([n] is their reference number):", ...input.cards);
|
||||
} else {
|
||||
lines.push("", "NO CARDS were attached — judge only how clear the explanation itself was.");
|
||||
}
|
||||
|
||||
if (input.context.length > 0) {
|
||||
lines.push(
|
||||
"",
|
||||
"THE TEACHER'S NOTES (the only material you share):",
|
||||
"",
|
||||
input.context
|
||||
.map((chunk) => `### ${chunk.deckTitle} — ${chunk.section}\n${chunk.text}`)
|
||||
.join("\n\n"),
|
||||
);
|
||||
}
|
||||
|
||||
lines.push("", "Reply with the JSON object only. No code fences, no commentary.");
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
/** Sent as the final user turn when the session ends. */
|
||||
export const TEACH_SUMMARY_USER_INSTRUCTION = `The practice is finished. Write the recap now — JSON only, in the language of the session.`;
|
||||
|
||||
/** One repair attempt when the recap did not parse as JSON. */
|
||||
export const TEACH_SUMMARY_REPAIR_INSTRUCTION = `Your previous reply was not valid JSON matching the required shape. Reply with the JSON object only — no code fences, no commentary, same content and language.`;
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Retry helper */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
|
|
|||
|
|
@ -440,6 +440,48 @@ export const chatScopeSchema = z.discriminatedUnion("kind", [
|
|||
]);
|
||||
export type ChatScope = z.infer<typeof chatScopeSchema>;
|
||||
|
||||
/* Teach it back: sessions are chat conversations with a "teach" marker and
|
||||
the session's own record (which cards were checked, the recap). Kept inside
|
||||
the chat schema so history, backup and encrypted sync carry them for free. */
|
||||
|
||||
export const teachDifficultySchema = z.enum(["friendly", "skeptical"]);
|
||||
export type TeachDifficulty = z.infer<typeof teachDifficultySchema>;
|
||||
|
||||
/** One checklist card the student checks the explanation against. */
|
||||
export const teachCardRefSchema = z.object({
|
||||
ref: z.number().int().min(1).max(999),
|
||||
deckId: z.string().min(1).max(80),
|
||||
activityId: z.string().min(1).max(80),
|
||||
deckTitle: z.string().max(200),
|
||||
/** The prompt side of the card, for the recap's links. */
|
||||
label: z.string().max(300),
|
||||
});
|
||||
export type TeachCardRef = z.infer<typeof teachCardRefSchema>;
|
||||
|
||||
export const teachSummarySchema = z.object({
|
||||
well: z.array(z.string().max(800)).max(12),
|
||||
gaps: z
|
||||
.array(
|
||||
z.object({
|
||||
what: z.string().max(800),
|
||||
/** [n] reference into the session's cards, when one covers the gap. */
|
||||
ref: z.number().int().min(1).max(999).nullable().optional(),
|
||||
}),
|
||||
)
|
||||
.max(20),
|
||||
next: z.array(z.string().max(800)).max(6),
|
||||
});
|
||||
export type TeachSummary = z.infer<typeof teachSummarySchema>;
|
||||
|
||||
export const teachSessionSchema = z.object({
|
||||
difficulty: teachDifficultySchema,
|
||||
topic: z.string().max(200),
|
||||
scope: chatScopeSchema,
|
||||
cards: z.array(teachCardRefSchema).max(80),
|
||||
summary: teachSummarySchema.optional(),
|
||||
});
|
||||
export type TeachSession = z.infer<typeof teachSessionSchema>;
|
||||
|
||||
export const chatConversationSchema = z.object({
|
||||
id: z.string().min(1).max(80),
|
||||
title: z.string().max(200),
|
||||
|
|
@ -447,6 +489,9 @@ export const chatConversationSchema = z.object({
|
|||
scope: chatScopeSchema,
|
||||
model: z.string().max(120).optional(),
|
||||
provider: z.string().max(60).optional(),
|
||||
/** "chat" (or absent, for old records) / "teach". */
|
||||
mode: z.enum(["chat", "teach"]).optional(),
|
||||
teach: teachSessionSchema.optional(),
|
||||
createdAt: z.number(),
|
||||
updatedAt: z.number(),
|
||||
});
|
||||
|
|
|
|||
103
lib/speech-input.ts
Normal file
103
lib/speech-input.ts
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* Browser dictation for "teach it back" (and anything else that wants it):
|
||||
* the Web Speech API's recognition side. Chrome, Edge and some Safari builds
|
||||
* expose it as `SpeechRecognition` / `webkitSpeechRecognition`; other engines
|
||||
* do not, so the UI asks `speechInputAvailable()` first and simply hides the
|
||||
* mic when the answer is no.
|
||||
*
|
||||
* Nothing leaves the app ourselves: the audio goes to whatever recogniser the
|
||||
* browser uses, exactly like the system dictation key on a phone keyboard.
|
||||
* The API types are not in TypeScript's DOM lib, so the minimal shapes live
|
||||
* here rather than leaking `any`.
|
||||
*/
|
||||
|
||||
type RecognitionResultLike = { isFinal: boolean; 0: { transcript: string } };
|
||||
type RecognitionEventLike = { resultIndex: number; results: ArrayLike<RecognitionResultLike> };
|
||||
type RecognitionLike = {
|
||||
lang: string;
|
||||
continuous: boolean;
|
||||
interimResults: boolean;
|
||||
maxAlternatives: number;
|
||||
start(): void;
|
||||
stop(): void;
|
||||
abort(): void;
|
||||
onresult: ((event: RecognitionEventLike) => void) | null;
|
||||
onerror: (() => void) | null;
|
||||
onend: (() => void) | null;
|
||||
};
|
||||
type RecognitionCtor = new () => RecognitionLike;
|
||||
|
||||
function recognitionCtor(): RecognitionCtor | null {
|
||||
if (typeof window === "undefined") return null;
|
||||
const scope = window as unknown as { SpeechRecognition?: RecognitionCtor; webkitSpeechRecognition?: RecognitionCtor };
|
||||
return scope.SpeechRecognition ?? scope.webkitSpeechRecognition ?? null;
|
||||
}
|
||||
|
||||
export function speechInputAvailable(): boolean {
|
||||
return recognitionCtor() !== null;
|
||||
}
|
||||
|
||||
export type SpeechInput = { stop: () => void };
|
||||
|
||||
/**
|
||||
* Starts listening. Final phrases arrive through `onText` (append them to the
|
||||
* draft); `onEnd` fires exactly once when listening stops, however it stops.
|
||||
* Returns null when the browser cannot listen or `start()` fails.
|
||||
*/
|
||||
export function startSpeechInput(options: {
|
||||
/** BCP-47 tag, usually `speechLocale(deck.language, getLocale())`. */
|
||||
lang: string;
|
||||
onText: (text: string) => void;
|
||||
onEnd: () => void;
|
||||
}): SpeechInput | null {
|
||||
const Ctor = recognitionCtor();
|
||||
if (!Ctor) return null;
|
||||
let recognition: RecognitionLike;
|
||||
try {
|
||||
recognition = new Ctor();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
let finished = false;
|
||||
const finish = () => {
|
||||
if (finished) return;
|
||||
finished = true;
|
||||
options.onEnd();
|
||||
};
|
||||
|
||||
recognition.lang = options.lang;
|
||||
recognition.continuous = true;
|
||||
recognition.interimResults = true;
|
||||
recognition.maxAlternatives = 1;
|
||||
recognition.onresult = (event) => {
|
||||
for (let index = event.resultIndex; index < event.results.length; index += 1) {
|
||||
const result = event.results[index];
|
||||
if (!result?.isFinal) continue;
|
||||
const transcript = result[0]?.transcript?.trim();
|
||||
if (transcript) options.onText(transcript);
|
||||
}
|
||||
};
|
||||
recognition.onerror = finish;
|
||||
recognition.onend = finish;
|
||||
|
||||
try {
|
||||
recognition.start();
|
||||
} catch {
|
||||
finish();
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
stop() {
|
||||
try {
|
||||
recognition.stop();
|
||||
} catch {
|
||||
/* already stopped */
|
||||
}
|
||||
finish();
|
||||
},
|
||||
};
|
||||
}
|
||||
194
lib/teach.ts
Normal file
194
lib/teach.ts
Normal file
|
|
@ -0,0 +1,194 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* Teach it back: session logic shared by the UI and the API client.
|
||||
*
|
||||
* A session is a "chat" with `mode: "teach"`: the user explains, the AI is a
|
||||
* curious student. This module owns the three things that must agree between
|
||||
* the start screen, the session and the recap:
|
||||
*
|
||||
* 1. how many exchanges a session lasts (`TEACH_MAX_EXCHANGES` — the single
|
||||
* constant; the prompt and the header counter both read it),
|
||||
* 2. the card checklist: which of the user's own cards the student may check
|
||||
* the explanation against, rendered as "[n] question → answer" lines for
|
||||
* the model and as `TeachCardRef`s for the recap's links,
|
||||
* 3. parsing the recap JSON the model returns.
|
||||
*/
|
||||
|
||||
import {
|
||||
teachSummarySchema,
|
||||
type Activity,
|
||||
type ChatScope,
|
||||
type Deck,
|
||||
type TeachCardRef,
|
||||
type TeachSummary,
|
||||
} from "./schemas";
|
||||
|
||||
/**
|
||||
* How many answers the teacher gives before the student wraps up and the
|
||||
* recap is written. One place to tune the length of a session.
|
||||
*/
|
||||
export const TEACH_MAX_EXCHANGES = 10;
|
||||
|
||||
/** Cards per session and per deck: enough to check against, small enough for
|
||||
* a prompt (and for the student to actually get through). */
|
||||
export const TEACH_MAX_CARDS = 48;
|
||||
export const TEACH_CARDS_PER_DECK = 6;
|
||||
|
||||
/** The question side of a card — also what the recap links show. */
|
||||
export function cardPrompt(activity: Activity): string {
|
||||
switch (activity.type) {
|
||||
case "flashcard":
|
||||
return activity.front;
|
||||
case "multiple_choice":
|
||||
return activity.question;
|
||||
case "true_false":
|
||||
return activity.statement;
|
||||
case "fill_blank":
|
||||
return activity.sentence;
|
||||
case "matching":
|
||||
case "ordering":
|
||||
return activity.instruction;
|
||||
case "short_answer":
|
||||
return activity.question;
|
||||
}
|
||||
}
|
||||
|
||||
/** The answer side of a card, as plain text for the prompt. */
|
||||
export function cardAnswer(activity: Activity): string {
|
||||
switch (activity.type) {
|
||||
case "flashcard":
|
||||
return activity.back;
|
||||
case "multiple_choice":
|
||||
return activity.options[activity.correctIndex] ?? "";
|
||||
case "true_false":
|
||||
return activity.answer ? "true" : "false";
|
||||
case "fill_blank":
|
||||
return activity.answer;
|
||||
case "matching":
|
||||
return activity.pairs.map((pair) => `${pair.term} = ${pair.definition}`).join("; ");
|
||||
case "ordering":
|
||||
return activity.items.join(" → ");
|
||||
case "short_answer":
|
||||
return activity.referenceAnswer;
|
||||
}
|
||||
}
|
||||
|
||||
/** The decks a scope covers (the same rule chat uses, activities required). */
|
||||
export function scopedDecks(decks: Deck[], scope: ChatScope): Deck[] {
|
||||
if (scope.kind === "none") return [];
|
||||
if (scope.kind === "all") return decks.filter((deck) => deck.activities.length > 0);
|
||||
return decks.filter((deck) => scope.deckIds.includes(deck.id) && deck.activities.length > 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* The checklist for one session: up to `TEACH_CARDS_PER_DECK` cards from every
|
||||
* scoped deck, numbered in order. `lines` is what the prompt shows the model
|
||||
* ("[3] What drives the flow? → A pressure difference"); `cards` keeps the
|
||||
* same references for the recap's links and for continuing a saved session.
|
||||
*/
|
||||
export function buildTeachCards(input: {
|
||||
decks: Deck[];
|
||||
scope: ChatScope;
|
||||
perDeck?: number;
|
||||
limit?: number;
|
||||
}): { cards: TeachCardRef[]; lines: string[] } {
|
||||
const perDeck = input.perDeck ?? TEACH_CARDS_PER_DECK;
|
||||
const limit = input.limit ?? TEACH_MAX_CARDS;
|
||||
const cards: TeachCardRef[] = [];
|
||||
const lines: string[] = [];
|
||||
for (const deck of scopedDecks(input.decks, input.scope)) {
|
||||
for (const activity of deck.activities.slice(0, perDeck)) {
|
||||
if (cards.length >= limit) break;
|
||||
const prompt = cardPrompt(activity).trim();
|
||||
const answer = cardAnswer(activity).trim();
|
||||
if (!prompt || !answer) continue;
|
||||
const ref = cards.length + 1;
|
||||
cards.push({
|
||||
ref,
|
||||
deckId: deck.id,
|
||||
activityId: activity.id,
|
||||
deckTitle: deck.title,
|
||||
label: prompt.slice(0, 300),
|
||||
});
|
||||
lines.push(`[${ref}] ${prompt.slice(0, 400)} → ${answer.slice(0, 400)}`.slice(0, 900));
|
||||
}
|
||||
}
|
||||
return { cards, lines };
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuilds the prompt lines for a saved session's cards (the answers are not
|
||||
* stored with the session, they come from the deck). Cards whose activity has
|
||||
* since gone fall back to their stored label.
|
||||
*/
|
||||
export function teachCardLines(cards: TeachCardRef[], decks: Deck[]): string[] {
|
||||
return cards.map((card) => {
|
||||
const deck = decks.find((entry) => entry.id === card.deckId);
|
||||
const activity = deck?.activities.find((entry) => entry.id === card.activityId);
|
||||
if (!activity) return `[${card.ref}] ${card.label} → (card no longer in the deck)`.slice(0, 900);
|
||||
return `[${card.ref}] ${cardPrompt(activity).slice(0, 400)} → ${cardAnswer(activity).slice(0, 400)}`.slice(0, 900);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The recap JSON the model returns, cleaned up: fences stripped, the object
|
||||
* located, strings trimmed, empty bullets dropped, card references clamped to
|
||||
* the session's cards. Null when the reply cannot be used.
|
||||
*/
|
||||
export function parseTeachSummary(raw: string, cards: TeachCardRef[] = []): TeachSummary | null {
|
||||
const text = raw.trim().replace(/^```(?:json)?\s*/i, "").replace(/```\s*$/, "");
|
||||
const start = text.indexOf("{");
|
||||
const end = text.lastIndexOf("}");
|
||||
if (start < 0 || end <= start) return null;
|
||||
let decoded: unknown;
|
||||
try {
|
||||
decoded = JSON.parse(text.slice(start, end + 1));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
/* The prompt asks for "card" (the [n] reference); the stored shape calls it
|
||||
"ref" — accept both so a recap is never lost to wording. */
|
||||
let candidate = decoded;
|
||||
if (decoded && typeof decoded === "object" && Array.isArray((decoded as { gaps?: unknown }).gaps)) {
|
||||
const record = decoded as { gaps: Record<string, unknown>[] };
|
||||
candidate = {
|
||||
...record,
|
||||
gaps: record.gaps.map((gap) =>
|
||||
gap && typeof gap === "object" && gap.ref == null ? { ...gap, ref: gap.card ?? null } : gap,
|
||||
),
|
||||
};
|
||||
}
|
||||
const parsed = teachSummarySchema.safeParse(candidate);
|
||||
if (!parsed.success) return null;
|
||||
const refs = new Set(cards.map((card) => card.ref));
|
||||
const clean = (value: string) => value.replace(/\s+/g, " ").trim();
|
||||
const summary: TeachSummary = {
|
||||
well: parsed.data.well.map(clean).filter(Boolean).slice(0, 4),
|
||||
gaps: parsed.data.gaps
|
||||
.map((gap) => ({
|
||||
what: clean(gap.what),
|
||||
ref: gap.ref != null && refs.has(gap.ref) ? gap.ref : null,
|
||||
}))
|
||||
.filter((gap) => gap.what.length > 0)
|
||||
.slice(0, 6),
|
||||
next: parsed.data.next.map(clean).filter(Boolean).slice(0, 2),
|
||||
};
|
||||
return summary;
|
||||
}
|
||||
|
||||
/** A recap gap as a flashcard, for the "turn gaps into cards" flow. */
|
||||
export function gapCardFrom(activity: Activity): { front: string; back: string } {
|
||||
return {
|
||||
front: cardPrompt(activity).trim().slice(0, 600),
|
||||
back: cardAnswer(activity).trim().slice(0, 600),
|
||||
};
|
||||
}
|
||||
|
||||
/** The generation focus for gap cards: one flashcard per gap, capped to what
|
||||
* the route accepts (focus is max 300 chars). */
|
||||
export function gapFocus(gaps: { what: string }[]): string {
|
||||
const lines = gaps.map((gap, index) => `${index + 1}. ${gap.what.replace(/\s+/g, " ").trim()}`);
|
||||
const header = "Turn each gap below into one flashcard (type: flashcard only, one card per gap, answers worded as complete sentences): ";
|
||||
return `${header}${lines.join(" ")}`.slice(0, 300);
|
||||
}
|
||||
28
lib/use-keyboard-inset.ts
Normal file
28
lib/use-keyboard-inset.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
|
||||
/**
|
||||
* Keeps a chat-style composer above the on-screen keyboard. The visual
|
||||
* viewport tells us how much of the window the keyboard covers; the screen
|
||||
* reads it as bottom padding via `--chat-keyboard` (WebKit does not resize the
|
||||
* layout viewport, so `bottom: 0` alone slides under the keyboard there).
|
||||
*/
|
||||
export function useKeyboardInset(): void {
|
||||
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");
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "freezmo",
|
||||
"version": "0.8.0",
|
||||
"version": "0.8.1",
|
||||
"private": true,
|
||||
"license": "GPL-3.0-only",
|
||||
"scripts": {
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
*
|
||||
* Bumping CACHE_VERSION is how a new deploy invalidates old assets.
|
||||
*/
|
||||
const CACHE_VERSION = "freezmo-v102";
|
||||
const CACHE_VERSION = "freezmo-v103";
|
||||
const SHELL_CACHE = `${CACHE_VERSION}-shell`;
|
||||
const ASSET_CACHE = `${CACHE_VERSION}-assets`;
|
||||
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ const SUITES = [
|
|||
"phase2-test",
|
||||
"phase3-test",
|
||||
"podcast-test",
|
||||
"teach-test",
|
||||
"security-test",
|
||||
"chat-test",
|
||||
"providers-test",
|
||||
|
|
|
|||
473
tests/teach-test.mjs
Normal file
473
tests/teach-test.mjs
Normal file
|
|
@ -0,0 +1,473 @@
|
|||
/**
|
||||
* Teach it back: the student prompt on the chat route, the session flow (start
|
||||
* → explain → curious questions → recap), the recap's two follow-ups (put the
|
||||
* missed cards on the review schedule, turn the gaps into new flashcards) and
|
||||
* the session history.
|
||||
*
|
||||
* Runs against a stub Ollama, so no real model is needed: the stub counts the
|
||||
* turns and answers each one with a question, which lets the test drive the
|
||||
* exact conversation it wants to see.
|
||||
*/
|
||||
import http from "node:http";
|
||||
import { mkdirSync } from "node:fs";
|
||||
|
||||
import { DECK, launchBrowser, makeChecker, makePage, portInUse, readLearning, seed, spawnApp, waitForHttp } from "./lib.mjs";
|
||||
|
||||
const { check, finish } = makeChecker();
|
||||
const browser = await launchBrowser();
|
||||
|
||||
const STUB_PORT = 11999;
|
||||
const APP_PORT = 3214;
|
||||
const STUB_BASE = `http://127.0.0.1:${STUB_PORT}`;
|
||||
const APP = `http://127.0.0.1:${APP_PORT}`;
|
||||
|
||||
/** What the recap stub returns for the finished session. */
|
||||
const SUMMARY_REPLY = {
|
||||
well: ["Why the sky is blue", "Molecules scatter short wavelengths"],
|
||||
gaps: [
|
||||
{ what: "Which colours scatter the most, and why violet is not the sky's colour", card: 1 },
|
||||
{ what: "Something only the explanation covered", card: null },
|
||||
],
|
||||
next: ["Re-explain the scattering with one concrete wavelength", "Add a card about the missing colours"],
|
||||
};
|
||||
|
||||
/** The gap-card stub: four cards, one of them not a flashcard (mapped when save). */
|
||||
const GAP_REPLY = {
|
||||
title: "Gap cards",
|
||||
subject: "Physics",
|
||||
language: "English",
|
||||
summary: "Cards for the missed ideas.",
|
||||
activities: [
|
||||
{ type: "flashcard", topic: "Scattering", front: "GAP-FRONT-1 which colours scatter most?", back: "GAP-BACK-1 the short ones, like blue", explanation: "Rayleigh scattering is stronger for short wavelengths." },
|
||||
{ type: "flashcard", topic: "Scattering", front: "GAP-FRONT-2 why does the sky look blue?", back: "GAP-BACK-2 scattered blue reaches your eyes", explanation: "Short wavelengths scatter far more." },
|
||||
{ type: "flashcard", topic: "Scattering", front: "GAP-FRONT-3 what scatters the light?", back: "GAP-BACK-3 air molecules", explanation: "The molecules, not dust, do most of it." },
|
||||
{ type: "multiple_choice", topic: "Scattering", question: "GAP-FRONT-4 what happens to long wavelengths?", options: ["GAP-BACK-4 they pass straight through", "they vanish", "they turn blue", "they stop"], correctIndex: 0, explanation: "Long wavelengths scatter much less." },
|
||||
],
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Stub Ollama */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
let turns = 0;
|
||||
let lastChat = null;
|
||||
|
||||
const stub = http.createServer((request, response) => {
|
||||
if (request.url?.startsWith("/api/tags")) {
|
||||
response.writeHead(200, { "Content-Type": "application/json" });
|
||||
response.end(JSON.stringify({ models: [{ name: "stub-text" }, { name: "stub-vision" }] }));
|
||||
return;
|
||||
}
|
||||
let raw = "";
|
||||
request.on("data", (chunk) => (raw += chunk));
|
||||
request.on("end", () => {
|
||||
let body = {};
|
||||
try {
|
||||
body = JSON.parse(raw || "{}");
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
const system = String(body.messages?.[0]?.content ?? "");
|
||||
|
||||
if (request.url?.startsWith("/api/chat")) {
|
||||
lastChat = body;
|
||||
/* Gap cards: the activity designer wants the deck JSON (non-streamed). */
|
||||
if (system.includes("activity designer")) {
|
||||
response.writeHead(200, { "Content-Type": "application/json" });
|
||||
response.end(JSON.stringify({ message: { content: JSON.stringify(GAP_REPLY) } }));
|
||||
return;
|
||||
}
|
||||
/* The recap: JSON, streamed like any other answer. */
|
||||
if (system.includes("write the app's recap")) {
|
||||
const content = JSON.stringify(SUMMARY_REPLY);
|
||||
response.writeHead(200, { "Content-Type": "application/x-ndjson" });
|
||||
response.write(JSON.stringify({ message: { content: content.slice(0, 40) }, done: false }) + "\n");
|
||||
response.write(JSON.stringify({ message: { content: content.slice(40) }, done: false }) + "\n");
|
||||
response.write(JSON.stringify({ message: { content: "" }, done: true }) + "\n");
|
||||
response.end();
|
||||
return;
|
||||
}
|
||||
/* Student turns: one short question per answer. */
|
||||
turns += 1;
|
||||
const content = `STUDENT-Q${turns}: wait, why does that step work?`;
|
||||
response.writeHead(200, { "Content-Type": "application/x-ndjson" });
|
||||
response.write(JSON.stringify({ message: { content }, done: false }) + "\n");
|
||||
response.write(JSON.stringify({ message: { content: "" }, done: true }) + "\n");
|
||||
response.end();
|
||||
return;
|
||||
}
|
||||
response.writeHead(404);
|
||||
response.end();
|
||||
});
|
||||
});
|
||||
await new Promise((resolve) => stub.listen(STUB_PORT, "127.0.0.1", resolve));
|
||||
|
||||
check("teach app port is free", !(await portInUse(APP_PORT)));
|
||||
const syncDir = new URL("./.tmp/sync-teach", import.meta.url).pathname;
|
||||
mkdirSync(syncDir, { recursive: true });
|
||||
const app = spawnApp(APP_PORT, {
|
||||
OLLAMA_BASE_URL: STUB_BASE,
|
||||
OLLAMA_AUTH: "local",
|
||||
OLLAMA_VERIFY_KEY: "false",
|
||||
CHAT_MODELS: "stub-text,stub-vision",
|
||||
CHAT_MODEL_DEFAULT: "stub-text",
|
||||
SYNC_DIR: syncDir,
|
||||
});
|
||||
|
||||
async function chatPost(payload) {
|
||||
const response = await fetch(`${APP}/api/chat`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const contentType = response.headers.get("content-type") ?? "";
|
||||
if (contentType.includes("ndjson")) {
|
||||
const events = (await response.text())
|
||||
.trim()
|
||||
.split("\n")
|
||||
.filter(Boolean)
|
||||
.map((line) => JSON.parse(line));
|
||||
return { status: response.status, events, body: null };
|
||||
}
|
||||
return { status: response.status, events: null, body: await response.json().catch(() => null) };
|
||||
}
|
||||
|
||||
const text = (events) => (events ?? []).filter((event) => event.type === "token").map((event) => event.text).join("");
|
||||
|
||||
try {
|
||||
const up = await waitForHttp(`${APP}/api/health`);
|
||||
check("stub app instance is up", up);
|
||||
|
||||
if (up) {
|
||||
/* 1. API: one student turn -------------------------------------- */
|
||||
const turn = await chatPost({
|
||||
messages: [{ role: "user", content: "The sky is blue because molecules scatter short wavelengths." }],
|
||||
context: [{ deckId: "seed_deck", deckTitle: "Sky deck", section: "Notes", text: "RAYLEIGH-SCATTERING-NOTES." }],
|
||||
mode: "teach",
|
||||
teach: {
|
||||
topic: "Why the sky is blue",
|
||||
difficulty: "skeptical",
|
||||
exchange: 0,
|
||||
limit: 10,
|
||||
cards: ["[1] What colour is a clear daytime sky? → Blue"],
|
||||
},
|
||||
});
|
||||
const turnSystem = String(lastChat?.messages?.[0]?.content ?? "");
|
||||
check("api: a teach turn streams the student's question", turn.status === 200 && text(turn.events).includes("STUDENT-Q"), JSON.stringify(turn.body));
|
||||
check(
|
||||
"api: the student prompt carries the role, the cards and the tone",
|
||||
turnSystem.includes("You are the STUDENT") &&
|
||||
turnSystem.includes("[1] What colour is a clear daytime sky?") &&
|
||||
turnSystem.includes("skeptical student"),
|
||||
);
|
||||
check("api: the student prompt carries the notes context", turnSystem.includes("RAYLEIGH-SCATTERING-NOTES"));
|
||||
check("api: the student prompt forbids grading", turnSystem.includes("NEVER judge the explanation"));
|
||||
|
||||
/* 2. API: the recap shape --------------------------------------- */
|
||||
const recap = await chatPost({
|
||||
messages: [
|
||||
{ role: "user", content: "The sky is blue because molecules scatter short wavelengths." },
|
||||
{ role: "assistant", content: "Why do the short ones scatter more?" },
|
||||
{ role: "user", content: "Because the molecules are comparable in size." },
|
||||
],
|
||||
mode: "teach_summary",
|
||||
teach: { topic: "Why the sky is blue", difficulty: "friendly", exchange: 2, limit: 10, cards: ["[1] What colour is a clear daytime sky? → Blue"] },
|
||||
});
|
||||
const recapSystem = String(lastChat?.messages?.[0]?.content ?? "");
|
||||
check(
|
||||
"api: the recap prompt asks for the JSON shape",
|
||||
recap.status === 200 && recapSystem.includes('"well"') && recapSystem.includes('"gaps"') && recapSystem.includes('"next"'),
|
||||
);
|
||||
check("api: the recap streams plain JSON", text(recap.events).startsWith('{"well"'));
|
||||
|
||||
/* 3. API: mode guards ------------------------------------------- */
|
||||
const missing = await chatPost({
|
||||
messages: [{ role: "user", content: "hi" }],
|
||||
mode: "teach",
|
||||
});
|
||||
check("api: a teach mode without its payload → 400", missing.status === 400, JSON.stringify(missing.body));
|
||||
const badMode = await chatPost({
|
||||
messages: [{ role: "user", content: "hi" }],
|
||||
mode: "roleplay",
|
||||
});
|
||||
check("api: an unknown mode → 400", badMode.status === 400, JSON.stringify(badMode.body));
|
||||
const plain = await chatPost({ messages: [{ role: "user", content: "Why is the sky blue?" }] });
|
||||
check(
|
||||
"api: plain chat is untouched",
|
||||
plain.status === 200 && String(lastChat?.messages?.[0]?.content ?? "").includes("chat tutor"),
|
||||
);
|
||||
|
||||
/* 4. UI: entry points -------------------------------------------- */
|
||||
const { context, page } = await makePage(browser, { width: 1280, height: 900 });
|
||||
await seed(page, { decks: [DECK] });
|
||||
|
||||
await page.goto(`${APP}/`, { waitUntil: "networkidle" });
|
||||
const sidebarLink = page.locator('a[href="/teach"]').first();
|
||||
check("ui: the sidebar has a Teach it back entry", (await sidebarLink.count()) > 0 && (await sidebarLink.getAttribute("title")) === "Teach it back");
|
||||
|
||||
await page.locator('button[aria-label="Deck actions"]').first().click();
|
||||
const deckLink = page.locator('[data-testid="deck-actions"] a[href="/teach?deck=seed_deck"]');
|
||||
check("ui: the deck's actions offer Teach it back", (await deckLink.count()) > 0);
|
||||
|
||||
/* 5. UI: the start screen ---------------------------------------- */
|
||||
await page.goto(`${APP}/teach?deck=seed_deck`, { waitUntil: "networkidle" });
|
||||
await page.waitForTimeout(600);
|
||||
const topicInput = page.locator("[data-teach-topic]");
|
||||
check("ui: a deck brings its topic along", (await topicInput.inputValue()) === "Sky deck", await topicInput.inputValue());
|
||||
check(
|
||||
"ui: the mode explains itself and shows its empty state later",
|
||||
(await page.getByText("You explain. The AI plays your student.").count()) > 0,
|
||||
);
|
||||
await page.getByRole("button", { name: "Skeptical student" }).click();
|
||||
check(
|
||||
"ui: the difficulty is pickable",
|
||||
(await page.getByRole("button", { name: "Skeptical student" }).getAttribute("aria-pressed")) === "true",
|
||||
);
|
||||
|
||||
/* 6. UI: a session ----------------------------------------------- */
|
||||
turns = 0;
|
||||
await page.getByTestId("teach-start").click();
|
||||
await page.waitForTimeout(400);
|
||||
check("ui: starting opens the session with its own empty state", (await page.getByText("You are the teacher now").count()) > 0);
|
||||
|
||||
const composer = page.locator("textarea").last();
|
||||
await composer.fill("The sky is blue because molecules scatter short wavelengths.");
|
||||
await composer.press("Enter");
|
||||
await page.getByText("STUDENT-Q1", { exact: false }).first().waitFor({ timeout: 15_000 });
|
||||
check("ui: the student answers with a question", true);
|
||||
const promptSystem = String(lastChat?.messages?.[0]?.content ?? "");
|
||||
check(
|
||||
"ui: the student saw the deck's cards and the session's topic",
|
||||
promptSystem.includes("[1] What colour is a clear daytime sky?") && promptSystem.includes("Sky deck"),
|
||||
);
|
||||
check("ui: the header counts the exchanges", (await page.getByText("1/10").count()) > 0);
|
||||
|
||||
await composer.fill("Rayleigh scattering makes the shorter wavelengths scatter far more.");
|
||||
await composer.press("Enter");
|
||||
await page.getByText("STUDENT-Q2", { exact: false }).first().waitFor({ timeout: 15_000 });
|
||||
|
||||
/* 7. UI: finish → recap ------------------------------------------ */
|
||||
await page.getByRole("button", { name: "Finish" }).click();
|
||||
await page.getByTestId("teach-recap").waitFor({ timeout: 15_000 });
|
||||
await page.getByText("Explained well").waitFor({ timeout: 15_000 });
|
||||
check("ui: the recap lists what was explained well", (await page.getByText("Why the sky is blue").count()) > 0);
|
||||
const gapLink = page.locator('[data-testid="teach-recap"] a[href="/deck/seed_deck"]');
|
||||
check("ui: a gap links to the card's deck", (await gapLink.count()) > 0);
|
||||
check("ui: the recap suggests next steps", (await page.getByText("Re-explain the scattering with one concrete wavelength").count()) > 0);
|
||||
|
||||
/* 8. UI: put the missed cards on the review schedule -------------- */
|
||||
await page.getByTestId("teach-review").click();
|
||||
await page.waitForTimeout(700);
|
||||
const learning = await readLearning(page);
|
||||
const scheduled = learning.srs.filter((record) => record.deckId === "seed_deck");
|
||||
check(
|
||||
"ui: the missed cards are scheduled for review now",
|
||||
scheduled.length >= 1 && scheduled.every((record) => record.dueAt <= Date.now()),
|
||||
JSON.stringify(scheduled.map((record) => record.key)),
|
||||
);
|
||||
|
||||
/* 9. UI: turn the gaps into cards ---------------------------------- */
|
||||
await page.getByTestId("teach-gap-cards").click();
|
||||
await page.getByTestId("teach-gap-panel").waitFor();
|
||||
check(
|
||||
"ui: the gap cards default to the session's deck",
|
||||
(await page.locator("[data-testid=teach-gap-panel] select").inputValue()) === "seed_deck",
|
||||
);
|
||||
await page.getByRole("button", { name: "Create the cards" }).click();
|
||||
await page.getByText("Review the cards").waitFor({ timeout: 20_000 });
|
||||
const frontInputs = page.locator("[data-testid=teach-gap-panel] input:not([type=checkbox])");
|
||||
const backs = page.locator("[data-testid=teach-gap-panel] textarea");
|
||||
check("ui: every proposed card can be edited before saving", (await frontInputs.count()) === 4 && (await backs.count()) === 4);
|
||||
check(
|
||||
"ui: a non-flashcard card is offered as a card too",
|
||||
(await backs.nth(3).inputValue()).includes("GAP-BACK-4"),
|
||||
await backs.nth(3).inputValue(),
|
||||
);
|
||||
await frontInputs.first().fill("EDITED-GAP-FRONT");
|
||||
await page.locator("[data-testid=teach-gap-panel] input[type=checkbox]").last().uncheck();
|
||||
await page.getByTestId("teach-save-cards").click();
|
||||
await page.waitForTimeout(700);
|
||||
const savedDecks = await page.evaluate(() => JSON.parse(window.localStorage.getItem("freezmo.decks.v1") ?? "[]"));
|
||||
const savedDeck = savedDecks.find((entry) => entry.id === "seed_deck");
|
||||
const added = (savedDeck?.activities ?? []).filter((activity) => activity.type === "flashcard" && String(activity.front).includes("GAP-FRONT"));
|
||||
check(
|
||||
"ui: saving adds the reviewed cards to the deck",
|
||||
added.length === 3 && added.some((activity) => activity.front === "EDITED-GAP-FRONT"),
|
||||
JSON.stringify(added.map((activity) => activity.front)),
|
||||
);
|
||||
check(
|
||||
"ui: the unchecked card was left out",
|
||||
!(savedDeck?.activities ?? []).some((activity) => String(activity.front).includes("GAP-FRONT-4")),
|
||||
);
|
||||
|
||||
/* 10. UI: the session is in the history --------------------------- */
|
||||
await page.goto(`${APP}/teach`, { waitUntil: "networkidle" });
|
||||
await page.waitForTimeout(600);
|
||||
const recent = page.getByText("Sky deck", { exact: true });
|
||||
check("ui: the finished session appears in the recent list", (await recent.count()) > 0);
|
||||
await recent.first().click();
|
||||
await page.getByTestId("teach-recap").waitFor({ timeout: 10_000 });
|
||||
check("ui: reopening the session brings the recap back", (await page.getByText("Explained well").count()) > 0);
|
||||
await page.getByRole("button", { name: "New session" }).click();
|
||||
check("ui: New session returns to the start screen", (await page.getByTestId("teach-start").count()) > 0);
|
||||
|
||||
/* 11. UI: an unfinished session continues after a reload ----------- */
|
||||
await page.locator("[data-teach-topic]").fill("Continuing reload");
|
||||
await page.getByTestId("teach-start").click();
|
||||
const reloadComposer = page.locator("textarea").last();
|
||||
await reloadComposer.fill("First part of the explanation.");
|
||||
await reloadComposer.press("Enter");
|
||||
await page.getByText("STUDENT-Q", { exact: false }).first().waitFor({ timeout: 15_000 });
|
||||
const sessionUrl = page.url();
|
||||
await page.goto(sessionUrl, { waitUntil: "networkidle" });
|
||||
await page.waitForTimeout(700);
|
||||
check("ui: an unfinished session reopens where it was", (await page.getByText("First part of the explanation.").count()) > 0);
|
||||
const beforeReload = (await page.evaluate(() => document.body.innerText.match(/STUDENT-Q/g)?.length ?? 0));
|
||||
const continueComposer = page.locator("textarea").last();
|
||||
await continueComposer.fill("Second part after the reload.");
|
||||
await continueComposer.press("Enter");
|
||||
await page.waitForFunction(
|
||||
(expected) => (document.body.innerText.match(/STUDENT-Q/g) ?? []).length > expected,
|
||||
beforeReload,
|
||||
{ timeout: 15_000 },
|
||||
);
|
||||
check("ui: the conversation carries on after the reload", true);
|
||||
|
||||
/* 11b. UI: sessions can be removed from the list ------------------- */
|
||||
await page.goto(`${APP}/teach`, { waitUntil: "networkidle" });
|
||||
await page.waitForTimeout(500);
|
||||
const doomed = page.locator("li", { hasText: "Continuing reload" }).first();
|
||||
await doomed.getByRole("button", { name: "Delete session" }).click();
|
||||
await page.waitForTimeout(400);
|
||||
check("ui: a session can be removed from the recent list", (await page.getByText("Continuing reload").count()) === 0);
|
||||
|
||||
/* 12. UI: teach sessions stay out of the chat history -------------- */
|
||||
await page.goto(`${APP}/chat`, { waitUntil: "networkidle" });
|
||||
await page.waitForTimeout(500);
|
||||
const chatSidebar = await page.locator("aside").first().innerText();
|
||||
check("ui: teach sessions do not leak into the chat list", !chatSidebar.includes("Sky deck"), chatSidebar.slice(0, 120));
|
||||
await context.close();
|
||||
|
||||
/* 13. UI: the exchange limit ends the session on its own ----------- */
|
||||
const { context: autoContext, page: autoPage } = await makePage(browser, { width: 390, height: 844, deviceScaleFactor: 1, isMobile: true, hasTouch: true });
|
||||
await seed(autoPage, { decks: [DECK] });
|
||||
await autoPage.goto(`${APP}/teach`, { waitUntil: "networkidle" });
|
||||
await autoPage.waitForTimeout(500);
|
||||
await autoPage.locator("[data-teach-topic]").fill("Scattering");
|
||||
await autoPage.getByTestId("teach-start").click();
|
||||
await autoPage.waitForTimeout(300);
|
||||
const mobileComposer = autoPage.locator("textarea").last();
|
||||
check("mobile: the composer is usable at 390px", await mobileComposer.isVisible());
|
||||
const turnsBefore = turns;
|
||||
for (let index = 0; index < 10; index += 1) {
|
||||
await mobileComposer.fill(`Explanation part ${index + 1}.`);
|
||||
/* Phones send with the button: Enter is a newline there. */
|
||||
await autoPage.getByRole("button", { name: "Send" }).click();
|
||||
/* Either the next question lands, or — on the last exchange — the
|
||||
session wraps itself up and the recap replaces the conversation. */
|
||||
await autoPage.waitForFunction(
|
||||
(expected) =>
|
||||
Boolean(document.querySelector("[data-testid=teach-recap]")) ||
|
||||
(document.body.innerText.match(/STUDENT-Q/g) ?? []).length >= expected,
|
||||
index + 1,
|
||||
{ timeout: 20_000 },
|
||||
);
|
||||
}
|
||||
await autoPage.getByTestId("teach-recap").waitFor({ timeout: 20_000 });
|
||||
check(
|
||||
"ui: the student ends the session after the exchange limit",
|
||||
(await autoPage.getByText("Explained well").count()) > 0 && turns === turnsBefore + 10,
|
||||
`${turnsBefore} -> ${turns}`,
|
||||
);
|
||||
await autoContext.close();
|
||||
|
||||
/* 14. UI: 360px, the narrow phone --------------------------------- */
|
||||
const { context: narrowContext, page: narrowPage } = await makePage(browser, { width: 360, height: 740, deviceScaleFactor: 1, isMobile: true, hasTouch: true });
|
||||
await seed(narrowPage, { decks: [DECK] });
|
||||
await narrowPage.goto(`${APP}/teach?deck=seed_deck`, { waitUntil: "networkidle" });
|
||||
await narrowPage.waitForTimeout(500);
|
||||
const narrowOverflow = await narrowPage.evaluate(() => document.documentElement.scrollWidth - window.innerWidth);
|
||||
const startButton = narrowPage.getByTestId("teach-start");
|
||||
const startBox = await startButton.boundingBox();
|
||||
check(
|
||||
"mobile: the start screen fits 360px and its action is on screen",
|
||||
narrowOverflow <= 1 && Boolean(startBox) && startBox.y + startBox.height <= 740 + 1,
|
||||
`overflow ${narrowOverflow}px`,
|
||||
);
|
||||
await narrowPage.getByTestId("teach-start").click();
|
||||
await narrowPage.waitForTimeout(300);
|
||||
const narrowComposer = await narrowPage.locator("textarea").last().boundingBox();
|
||||
check(
|
||||
"mobile: the composer stays on screen at 360px",
|
||||
Boolean(narrowComposer) && narrowComposer.y + narrowComposer.height <= 740 + 1,
|
||||
JSON.stringify(narrowComposer),
|
||||
);
|
||||
await narrowContext.close();
|
||||
|
||||
/* 15. UI: the mic appears only where dictation exists -------------- */
|
||||
const { context: plainContext, page: plainPage } = await makePage(browser, { width: 390, height: 844, isMobile: true, hasTouch: true });
|
||||
await seed(plainPage, { decks: [DECK] });
|
||||
/* Chromium ships a real recogniser; a browser without one is the case the
|
||||
mic must disappear in, so take both constructors off the window. */
|
||||
await plainPage.addInitScript(() => {
|
||||
Object.defineProperty(window, "SpeechRecognition", { configurable: true, value: undefined });
|
||||
Object.defineProperty(window, "webkitSpeechRecognition", { configurable: true, value: undefined });
|
||||
});
|
||||
await plainPage.goto(`${APP}/teach?deck=seed_deck`, { waitUntil: "networkidle" });
|
||||
await plainPage.getByTestId("teach-start").click();
|
||||
await plainPage.waitForTimeout(300);
|
||||
check("mic: hidden when the browser cannot dictate", (await plainPage.locator("[data-voice-button]").count()) === 0);
|
||||
await plainContext.close();
|
||||
|
||||
const { context: voiceContext, page: voicePage } = await makePage(browser, { width: 390, height: 844, isMobile: true, hasTouch: true });
|
||||
await seed(voicePage, { decks: [DECK] });
|
||||
await voicePage.addInitScript(() => {
|
||||
class FakeRecognition {
|
||||
constructor() {
|
||||
this.lang = "";
|
||||
this.continuous = false;
|
||||
this.interimResults = false;
|
||||
this.maxAlternatives = 1;
|
||||
this.onresult = null;
|
||||
this.onerror = null;
|
||||
this.onend = null;
|
||||
window.__recognition = this;
|
||||
}
|
||||
start() {}
|
||||
stop() {
|
||||
if (this.onend) this.onend();
|
||||
}
|
||||
abort() {}
|
||||
}
|
||||
/* Chrome ships both names; shadow them so the app uses the stub. */
|
||||
Object.defineProperty(window, "SpeechRecognition", { configurable: true, value: FakeRecognition });
|
||||
Object.defineProperty(window, "webkitSpeechRecognition", { configurable: true, value: FakeRecognition });
|
||||
});
|
||||
await voicePage.goto(`${APP}/teach?deck=seed_deck`, { waitUntil: "networkidle" });
|
||||
await voicePage.getByTestId("teach-start").click();
|
||||
await voicePage.waitForTimeout(300);
|
||||
const mic = voicePage.locator("[data-voice-button]");
|
||||
check("mic: shown when the browser can dictate", (await mic.count()) === 1);
|
||||
await mic.click();
|
||||
await voicePage.waitForTimeout(200);
|
||||
check("mic: the button reads as listening", (await mic.getAttribute("aria-pressed")) === "true");
|
||||
await voicePage.evaluate(() => {
|
||||
window.__recognition.onresult({
|
||||
resultIndex: 0,
|
||||
results: [{ isFinal: true, 0: { transcript: "the sky is blue" } }],
|
||||
});
|
||||
});
|
||||
check(
|
||||
"mic: dictated words land in the draft",
|
||||
(await voicePage.locator("textarea").last().inputValue()) === "the sky is blue",
|
||||
await voicePage.locator("textarea").last().inputValue(),
|
||||
);
|
||||
await voiceContext.close();
|
||||
}
|
||||
} finally {
|
||||
app.stop();
|
||||
stub.close();
|
||||
await browser.close();
|
||||
}
|
||||
|
||||
finish();
|
||||
Loading…
Add table
Add a link
Reference in a new issue