"use client"; import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react"; import { useRouter, useSearchParams } from "next/navigation"; import { useReducedMotion } from "framer-motion"; import Link from "next/link"; import { Composer, type PendingAttachment } from "./Composer"; import { ContextPicker } from "./ContextPicker"; import { HistorySheet } from "./HistoryPanel"; import { MessageRow } from "./MessageRow"; import { ModelPicker } from "./ModelPicker"; import { ChatIcon, ChevronDownIcon, ChevronLeftIcon, HistoryIcon, PlusIcon, RefreshIcon } from "@/components/icons"; 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 { buildChatContext, deckHasNotes } from "@/lib/chat-context"; 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 { chatMessageId, readStoredChatModel, storeChatModel, titleFromMessage, type ChatAttachment, type ChatMessage, type ChatModelChoice, type ChatScope, } from "@/lib/chat"; import { setDeckSeed } from "@/lib/deck-seed"; import { extractDocument } from "@/lib/documents"; import { folderStore } from "@/lib/folders"; import { isImageFile, prepareImage } from "@/lib/image"; import { useI18n } from "@/lib/i18n"; import { springGentle } from "@/lib/motion"; 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"; type ExtraChunk = { deckId: string; deckTitle: string; section: string; text: string }; /** * Chat mode: an LLM-style conversation that knows the user's notes. The * retrieval runs here in the browser (BM25 over the scoped decks), and only * the top chunks plus the recent turns travel with each request. */ export function ChatScreen() { 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 { health } = useHealth(); const deckParam = search.get("deck"); const [scope, setScope] = useState(() => deckParam ? { kind: "decks", deckIds: [deckParam] } : { kind: "all" }, ); const [seed, setSeed] = useState(null); const [messages, setMessages] = useState([]); const [draft, setDraft] = useState(""); const [attachments, setAttachments] = useState([]); const [attachBusy, setAttachBusy] = useState(false); const [model, setModel] = useState(""); const [busy, setBusy] = useState(false); const [pinned, setPinned] = useState(true); const [scopeOpen, setScopeOpen] = useState(false); const [modelOpen, setModelOpen] = useState(false); const [error, setError] = useState<{ message: string; kind?: string } | null>(null); const [conversationId, setConversationId] = useState(null); const [title, setTitle] = useState(""); const [historyOpen, setHistoryOpen] = useState(false); const [query, setQuery] = useState(""); const conversations = useSyncExternalStore( chatHistoryStore.subscribe, chatHistoryStore.getSnapshot, chatHistoryStore.getServerSnapshot, ); const abortRef = useRef(null); const scrollRef = useRef(null); const pinnedRef = useRef(true); const extrasRef = useRef>({}); /* Hand-offs from a deck menu / results screen. */ useEffect(() => { const taken = takeChatSeed(); if (!taken) return; setSeed(taken); if (taken.deckId) setScope({ kind: "decks", deckIds: [taken.deckId] }); }, []); useEffect(() => { setModel(readStoredChatModel() ?? ""); }, []); /* History: hydrate once, then open ?c= conversations. */ useEffect(() => { void ensureChatsHydrated(); }, []); const loadedRef = useRef(null); const loadConversation = useCallback((found: ChatConversation) => { setConversationId(found.id); setTitle(found.title); setMessages(conversationMessages(found)); setScope(found.scope); setSeed(null); setAttachments([]); setDraft(""); setError(null); if (found.model) setModel(found.model); loadedRef.current = found.id; chatActive.set(found.id); }, []); const conversationParam = search.get("c"); useEffect(() => { if (!conversationParam || loadedRef.current === conversationParam) return; const found = getConversation(conversationParam); if (!found) return; // hydration may still be running; re-runs when it lands 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 chatModels = useMemo(() => { 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 }, ]; return list.filter((entry) => entry.name); }, [health]); const activeModel = useMemo(() => { if (model && chatModels.some((entry) => entry.name === model)) return model; return chatModels[0]?.name ?? model; }, [model, chatModels]); const scopeText = useMemo(() => { if (scope.kind === "all") return t("chat.scope.all"); if (scope.kind === "none") return t("chat.scope.none"); return plural(scope.deckIds.length, "chat.scope.decks"); }, [scope, t, plural]); const scopedDecks = useMemo(() => { if (scope.kind === "none") return []; if (scope.kind === "all") return decks; return decks.filter((deck) => scope.deckIds.includes(deck.id)); }, [decks, scope]); const missingNotes = useMemo( () => (settings.chatNotes ? scopedDecks.some((deck) => !deckHasNotes(deck)) : false), [scopedDecks, settings.chatNotes], ); const suggestions = useMemo(() => { const chosen = scope.kind === "decks" ? scopedDecks : []; if (chosen.length === 1) { return [ t("chat.suggest.explain", { deck: chosen[0].title }), t("chat.suggest.quiz", { deck: chosen[0].title }), t("chat.suggest.summarize"), ]; } if (scope.kind === "none") return [t("chat.suggest.general"), t("chat.suggest.explainTopic")]; return [t("chat.suggest.summarize"), t("chat.suggest.quizAll"), t("chat.suggest.studyFirst")]; }, [scope, scopedDecks, t]); 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 (pinnedRef.current) scrollToBottom(false); }, [messages, scrollToBottom]); const onScroll = () => { const element = scrollRef.current; if (!element) return; const distance = element.scrollHeight - element.scrollTop - element.clientHeight; const next = distance < 80; pinnedRef.current = next; setPinned(next); }; const extraFor = (text: string, pending: PendingAttachment[]): ExtraChunk[] => { const chunks: ExtraChunk[] = []; if (seed) chunks.push({ deckId: seed.deckId ?? "seed", deckTitle: seed.label, section: seed.label, text: seed.text }); if (pending.length > 0) { chunks.push({ deckId: "attachment", deckTitle: t("chat.attachmentLabel"), section: t("chat.attachmentLabel"), text: pending.map((entry) => `${entry.attachment.name}\n${entry.text}`).join("\n\n").slice(0, 24_000), }); } void text; return chunks; }; const persist = useCallback( (nextMessages: ChatMessage[], options: { title?: string; model?: string; scope?: ChatScope } = {}) => { const id = conversationId ?? chatMessageId("chat"); if (!conversationId) { setConversationId(id); chatActive.set(id); // Keep the URL in step so a reload continues this conversation. // history.replaceState avoids a router round-trip for the same page. window.history.replaceState(null, "", `/chat?c=${id}`); } const firstUser = nextMessages.find((message) => message.role === "user"); const derived = title || (firstUser ? titleFromMessage(firstUser.content) : ""); const nextTitle = options.title ?? derived; const existing = getConversation(id); saveConversation({ id, title: nextTitle, messages: nextMessages, scope: options.scope ?? scope, model: options.model ?? (activeModel || undefined), createdAt: existing?.createdAt ?? Date.now(), updatedAt: Date.now(), }); if (nextTitle && nextTitle !== title) setTitle(nextTitle); }, [conversationId, title, scope, activeModel], ); const run = async (history: ChatMessage[], extraChunks: ExtraChunk[]) => { const lastUser = history[history.length - 1]; const context = buildChatContext({ decks, scope, question: lastUser.content, history: history.slice(0, -1), useNotes: settings.chatNotes, extraChunks, }); const sources = context.chunks.map(({ deckId, deckTitle, section }) => ({ deckId, deckTitle, section })); const assistantId = chatMessageId("a"); setMessages([...history, { id: assistantId, role: "assistant", content: "", sources, at: Date.now() }]); setBusy(true); pinnedRef.current = true; setPinned(true); const controller = new AbortController(); abortRef.current = controller; let produced = false; let finalContent = ""; let finalThinking = ""; let finalModel: string | undefined; let failure: { message: string; kind?: string } | null = null; try { await streamChat({ model: activeModel || undefined, 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: (text) => { finalThinking += text; setMessages((previous) => previous.map((message) => message.id === assistantId ? { ...message, thinking: (message.thinking ?? "") + text } : message, ), ); }, onDone: (modelName) => { finalModel = modelName; setMessages((previous) => previous.map((message) => (message.id === assistantId ? { ...message, model: modelName } : message)), ); }, }); } catch (error) { const apiError = error instanceof ApiError ? error : new ApiError(String(error), "unknown"); if (apiError.kind === "aborted") { // Stopped by the user: keep whatever streamed in. } else { failure = { message: apiError.message, kind: apiError.kind }; setError(failure); if (!produced) { // Nothing arrived: take the placeholder away and give the text back. 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); // Persist the settled turn: the question always, the answer when one // was produced (partial answers from a stop are kept too). const assistant: ChatMessage | null = produced ? { id: assistantId, role: "assistant", content: finalContent, thinking: finalThinking || undefined, model: finalModel, sources, at: Date.now(), ...(failure ? { error: failure } : {}), } : null; persist(assistant ? [...history, assistant] : history); } }; const send = (text?: string) => { const content = (text ?? draft).trim(); if (!content || busy || !online) return; const pending = attachments; const userMessage: ChatMessage = { id: chatMessageId("u"), role: "user", content, attachments: pending.length > 0 ? pending.map((entry) => entry.attachment) : undefined, at: Date.now(), }; const extras = extraFor(content, pending); extrasRef.current[userMessage.id] = extras; const history = [...messages, userMessage]; setError(null); setMessages(history); setDraft(""); setAttachments([]); void run(history, extras); }; const retryLast = () => { const lastUserIndex = messages.map((message) => message.role).lastIndexOf("user"); if (lastUserIndex < 0) return; const history = messages.slice(0, lastUserIndex + 1); setMessages(history); void run(history, extrasRef.current[history[lastUserIndex].id] ?? []); }; const regenerate = () => { const lastUserIndex = messages.map((message) => message.role).lastIndexOf("user"); if (lastUserIndex < 0) return; const history = messages.slice(0, lastUserIndex + 1); setMessages(history); void run(history, extrasRef.current[history[lastUserIndex].id] ?? []); }; const editLast = () => { const lastUserIndex = messages.map((message) => message.role).lastIndexOf("user"); if (lastUserIndex < 0) return; setDraft(messages[lastUserIndex].content); setMessages(messages.slice(0, lastUserIndex)); }; const resetView = useCallback(() => { abortRef.current?.abort(); loadedRef.current = null; setConversationId(null); setTitle(""); setMessages([]); setSeed(null); setAttachments([]); setDraft(""); setError(null); chatActive.set(null); window.history.replaceState(null, "", "/chat"); }, []); const newChat = () => { resetView(); setHistoryOpen(false); }; /* The sidebar can start a new chat (URL -> /chat) while this screen is open. */ useEffect(() => { if (conversationParam || !loadedRef.current) return; resetView(); }, [conversationParam, resetView]); /* ...and delete the conversation that is currently open. */ useEffect(() => { if (!conversationId || !loadedRef.current) return; if (getConversation(conversationId)) return; resetView(); }, [conversations, conversationId, resetView]); const openConversation = (id: string) => { abortRef.current?.abort(); setHistoryOpen(false); const found = getConversation(id); if (found) loadConversation(found); router.push(`/chat?c=${id}`); }; const handleDeleteConversation = (conversation: ChatConversation) => { deleteConversation(conversation.id); if (conversation.id === conversationId) { setConversationId(null); setTitle(""); setMessages([]); } showToast({ message: t("chat.deletedChat"), actionLabel: t("toast.undo"), onAction: () => saveConversation(conversation), }); }; /* The single scoped deck is the target for "Save to notes". */ const noteTargetDeck = useMemo(() => { if (scope.kind === "decks" && scope.deckIds.length === 1) { return decks.find((deck) => deck.id === scope.deckIds[0]); } if (seed?.deckId) return decks.find((deck) => deck.id === seed.deckId); return undefined; }, [scope, seed, decks]); const saveToNotes = (message: ChatMessage) => { if (!noteTargetDeck) return; const current = noteTargetDeck.sourceText?.trim() ?? ""; const addition = `\n\n— ${t("chat.savedFromChat")} —\n${message.content.trim()}`; let next = `${current}${addition}`; let trimmed = false; if (next.length > 40_000) { next = next.slice(-40_000); trimmed = true; } upsertDeck({ ...noteTargetDeck, sourceText: next, updatedAt: Date.now() }); showToast({ message: trimmed ? t("chat.savedToNotesTrimmed", { deck: noteTargetDeck.title }) : t("chat.savedToNotes", { deck: noteTargetDeck.title }), }); }; const toFlashcards = (message: ChatMessage) => { setDeckSeed({ text: message.content.slice(0, 40_000), step: 2 }); router.push("/create"); }; const handleAttach = async (file: File) => { setAttachBusy(true); try { if (isImageFile(file)) { const prepared = await prepareImage(file); const text = await extractChatAttachment({ images: [prepared.dataUrl] }); const attachment: ChatAttachment = { name: file.name || t("chat.attachPhoto"), kind: "photo", chars: text.length }; setAttachments((previous) => [...previous, { attachment, text }]); } else { const document = await extractDocument(file); if (!document.text.trim()) { showToast({ message: t("chat.attachNoText") }); } else { const attachment: ChatAttachment = { name: document.name, kind: "document", chars: document.text.length }; setAttachments((previous) => [...previous, { attachment, text: document.text }]); } } } catch (error) { showToast({ message: error instanceof ApiError ? error.message : t("chat.attachFailed") }); } finally { setAttachBusy(false); } }; const lastAssistantId = [...messages].reverse().find((message) => message.role === "assistant")?.id; const lastUserId = [...messages].reverse().find((message) => message.role === "user")?.id; const lastFailedAssistant = [...messages].reverse().find((message) => message.role === "assistant" && message.error)?.id; return (
{/* Desktop's conversation list lives in the main sidebar (on /chat). */}

{title || t("chat.title")}

{scopeText} {seed ? ` · ${seed.label}` : ""}

{/* Actions share one size, radius, border, background and hit area. */}
{messages.length === 0 ? (

{t("chat.emptyTitle")}

{t("chat.emptyHint")}

{missingNotes ? (

{t("chat.oldDecksNote")}

) : null}
{suggestions.map((suggestion) => ( ))}
) : ( <> {seed ? (
{t("chat.fromLabel")}: {seed.label}
) : null} {messages.map((message) => ( toFlashcards(message)} onSaveNotes={noteTargetDeck ? () => saveToNotes(message) : undefined} /> ))} )}
{!pinned && messages.length > 0 ? ( ) : null}
{error ? (

{error.message}

{messages.some((message) => message.role === "user") ? ( ) : null}
) : null}
send()} onStop={() => abortRef.current?.abort()} busy={busy} online={online} scopeLabel={scopeText} modelLabel={activeModel || t("chat.modelTitle")} onOpenScope={() => setScopeOpen(true)} onOpenModel={() => setModelOpen(true)} onAttachFile={(file) => void handleAttach(file)} attachBusy={attachBusy} attachments={attachments} onRemoveAttachment={(index) => setAttachments((previous) => previous.filter((_, i) => i !== index))} /> setModelOpen(false)} models={chatModels} value={activeModel} onChange={(next) => { setModel(next); storeChatModel(next); if (messages.length > 0) persist(messages, { model: next }); }} /> setScopeOpen(false)} scope={scope} onChange={(next) => { setScope(next); if (messages.length > 0) persist(messages, { scope: next }); }} decks={decks} folders={folders} />
{settings.chatNotes === false && scope.kind !== "none" ? (

{t("chat.notesOffNote")}{" "} {t("chat.notesOffLink")}

) : null}
setHistoryOpen(false)} conversations={conversations} currentId={conversationId} query={query} onQueryChange={setQuery} onOpen={openConversation} onRename={renameConversation} onDelete={handleDeleteConversation} onNew={newChat} />
); }