Chat mode, phase 1: grounded streaming chat over your notes

Entry points (no sixth tab): Ask in the Library header (desktop button,
phone icon), Ask about this deck in the deck menu, Ask a question on
study/homework results (compact result travels as context), and a Chat
row in the desktop sidebar. Phones get a full-screen route with back +
New chat; the tab bar steps aside.

- Client-side retrieval: chunk scoped decks (~800 tokens), BM25 against
  the question + recent turns, top chunks under ~7k tokens, each
  labelled with deck + section. Notes come from the deck's stored
  sourceText (already kept for Regenerate); decks without it fall back
  to generated content and the UI says so.
- Stateless server: /api/chat validates against the CHAT_MODELS
  allowlist, trims the history to ~6k tokens, caps context at ~9k and
  streams token/thinking/done/error NDJSON. /api/chat/extract reduces
  attachments to text through the existing vision step.
- UI: user bubbles + full-width assistant text, streaming with stop,
  auto-scroll that pauses with a jump-to-bottom button, safe markdown
  (React nodes, no HTML injection; KaTeX dynamically imported only when
  a message has math), Thinking row for reasoning, copy/regenerate/
  edit-and-resend, model + context pickers (last model remembered),
  composer with visualViewport keyboard handling, safe-area, 16px
  textarea, Enter-to-send on desktop.
- Usage-limit failures keep the draft and show the shared message with
  Settings/Ollama actions; stopping maps the reader abort to a clean
  stopped state; offline disables sending with a reason.
- Settings: Keep notes text for chat toggle; deck menu: Delete notes
  text. RATE_LIMIT_CHAT + CHAT_MODELS in .env.example and README.
- Tests: chat suite (63 checks, stub Ollama) wired into run-all.
  Full set: 12/12 suites, 487 checks. SW -> freezmo-v32.

Also: tests/model-compare.mjs (diagnostic) benchmarks two models on the
same notes (latency, repair rounds, activity types) so the gpt-oss vs
gemma default can be decided on numbers; defaults unchanged.
This commit is contained in:
Martin Dvořák 2026-10-11 18:32:11 +00:00
commit 756a6257a2
46 changed files with 3840 additions and 56 deletions

View file

@ -0,0 +1,32 @@
"use client";
import { useRouter } from "next/navigation";
import { ChatIcon } from "@/components/icons";
import { Button } from "@/components/ui";
import { setChatSeed, type ChatSeed } from "@/lib/chat-seed";
import { useI18n } from "@/lib/i18n";
import { cn } from "@/lib/utils";
/**
* "Ask a question" entry point for results screens: hands the compact result
* summary to the chat (sessionStorage, one-shot) and opens it.
*/
export function AskButton({ seed, className }: { seed: ChatSeed; className?: string }) {
const { t } = useI18n();
const router = useRouter();
return (
<Button
variant="secondary"
className={cn("touch-target", className)}
onClick={() => {
setChatSeed(seed);
router.push("/chat");
}}
>
<ChatIcon className="size-4" />
{t("chat.askQuestion")}
</Button>
);
}

View file

@ -0,0 +1,520 @@
"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 { MessageRow } from "./MessageRow";
import { ModelPicker } from "./ModelPicker";
import { ChatIcon, ChevronDownIcon, ChevronLeftIcon, 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 {
chatMessageId,
readStoredChatModel,
storeChatModel,
type ChatAttachment,
type ChatMessage,
type ChatModelChoice,
type ChatScope,
} from "@/lib/chat";
import { extractDocument } from "@/lib/documents";
import { folderStore } from "@/lib/folders";
import { isImageFile, prepareImage } from "@/lib/image";
import { useI18n } from "@/lib/i18n";
import { showToast } from "@/lib/toast";
import { deckStore } from "@/lib/storage";
import { settingsStore } from "@/lib/settings";
import { useHealth } from "@/lib/use-health";
import { useOnline } from "@/lib/use-online";
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<ChatScope>(() =>
deckParam ? { kind: "decks", deckIds: [deckParam] } : { kind: "all" },
);
const [seed, setSeed] = useState<ChatSeed | null>(null);
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [draft, setDraft] = useState("");
const [attachments, setAttachments] = useState<PendingAttachment[]>([]);
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 abortRef = useRef<AbortController | null>(null);
const scrollRef = useRef<HTMLDivElement>(null);
const pinnedRef = useRef(true);
const extrasRef = useRef<Record<string, ExtraChunk[]>>({});
/* 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() ?? "");
}, []);
/* 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<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 },
];
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 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;
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;
setMessages((previous) =>
previous.map((message) =>
message.id === assistantId ? { ...message, content: message.content + text } : message,
),
);
},
onThinking: (text) => {
setMessages((previous) =>
previous.map((message) =>
message.id === assistantId ? { ...message, thinking: (message.thinking ?? "") + text } : message,
),
);
},
onDone: (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 {
setError({ message: apiError.message, kind: apiError.kind });
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: { message: apiError.message, kind: apiError.kind } }
: message,
),
);
}
}
} finally {
abortRef.current = null;
setBusy(false);
}
};
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 newChat = () => {
abortRef.current?.abort();
setMessages([]);
setSeed(null);
setAttachments([]);
setDraft("");
};
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 (
<div className="relative flex h-full min-h-0 flex-col" style={{ paddingBottom: "var(--chat-keyboard, 0px)" }}>
<header className="glass sticky top-0 z-30 flex h-14 shrink-0 items-center gap-1.5 px-2 sm:px-4">
<button
type="button"
onClick={() => (window.history.length > 1 ? router.back() : router.push("/"))}
aria-label={t("chat.back")}
title={t("chat.back")}
className="grid size-10 shrink-0 place-items-center rounded-2xl 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)]">{t("chat.title")}</h1>
<p className="truncate text-[11.5px] text-[var(--text-subtle)]">
{scopeText}
{seed ? ` · ${seed.label}` : ""}
</p>
</div>
<ThemeToggle className="hidden lg:grid" />
<button
type="button"
onClick={newChat}
aria-label={t("chat.new")}
title={t("chat.new")}
className="flex h-10 shrink-0 items-center gap-1.5 rounded-2xl border border-[var(--glass-border)] bg-[var(--surface-strong)] px-3 text-[12.5px] font-medium text-[var(--text-muted)] transition hover:bg-[var(--hover)] hover:text-[var(--text)]"
>
<PlusIcon className="size-4" />
<span className="hidden sm:inline">{t("chat.new")}</span>
</button>
</header>
<div className="relative min-h-0 flex-1">
<div ref={scrollRef} onScroll={onScroll} className="h-full overflow-y-auto overscroll-contain">
<div className="mx-auto w-full max-w-3xl space-y-4 px-4 py-4">
{messages.length === 0 ? (
<div className="space-y-5 py-6">
<div className="text-center">
<ChatIcon className="mx-auto size-10 text-accent-400" />
<h2 className="mt-3 text-[20px] font-semibold tracking-[-0.01em] text-[var(--text)]">{t("chat.emptyTitle")}</h2>
<p className="mx-auto mt-1.5 max-w-sm text-[13.5px] leading-relaxed text-[var(--text-subtle)]">
{t("chat.emptyHint")}
</p>
</div>
{missingNotes ? (
<p className="rounded-2xl border border-amber-300/50 bg-amber-50/70 px-3.5 py-3 text-[12.5px] leading-relaxed text-amber-800 dark:border-amber-400/25 dark:bg-amber-400/10 dark:text-amber-200">
{t("chat.oldDecksNote")}
</p>
) : null}
<div className="flex flex-wrap justify-center gap-2">
{suggestions.map((suggestion) => (
<button
key={suggestion}
type="button"
disabled={!online || busy}
onClick={() => send(suggestion)}
className="max-w-full rounded-2xl border border-[var(--glass-border)] bg-[var(--surface-strong)] px-3.5 py-2.5 text-left text-[13px] text-[var(--text-muted)] transition hover:bg-[var(--hover)] hover:text-[var(--text)] disabled:opacity-55"
>
{suggestion}
</button>
))}
</div>
</div>
) : (
<>
{seed ? (
<div className="flex items-center gap-2 rounded-2xl border border-[var(--hairline-soft)] bg-[var(--surface-sunken)] px-3 py-2 text-[12px] text-[var(--text-subtle)]">
<span className="truncate">
{t("chat.fromLabel")}: {seed.label}
</span>
</div>
) : null}
{messages.map((message) => (
<MessageRow
key={message.id}
message={message}
streaming={busy && message.id === lastAssistantId}
isLastAssistant={message.id === lastAssistantId}
isLastUser={message.id === lastUserId}
busy={busy}
onRegenerate={regenerate}
onEdit={editLast}
onRetry={message.id === lastFailedAssistant ? retryLast : undefined}
/>
))}
</>
)}
<div className="h-2" aria-hidden />
</div>
</div>
{!pinned && messages.length > 0 ? (
<button
type="button"
onClick={() => {
pinnedRef.current = true;
setPinned(true);
scrollToBottom(true);
}}
aria-label={t("chat.jumpToBottom")}
title={t("chat.jumpToBottom")}
className="glass-panel absolute bottom-4 left-1/2 grid size-10 -translate-x-1/2 place-items-center rounded-full text-[var(--text-muted)] shadow-lg"
>
<ChevronDownIcon className="size-5" />
</button>
) : null}
</div>
{error ? (
<div
role="alert"
className="mx-3 mb-2 shrink-0 rounded-2xl 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>
<div className="mt-2 flex flex-wrap items-center gap-2">
{messages.some((message) => message.role === "user") ? (
<Button size="sm" variant="secondary" onClick={retryLast}>
<RefreshIcon className="size-4" /> {t("chat.retry")}
</Button>
) : null}
<ModelErrorActions kind={error.kind} />
</div>
</div>
) : null}
<div className="relative shrink-0">
<Composer
value={draft}
onChange={setDraft}
onSend={() => 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))}
/>
<ModelPicker
open={modelOpen}
onClose={() => setModelOpen(false)}
models={chatModels}
value={activeModel}
onChange={(next) => {
setModel(next);
storeChatModel(next);
}}
/>
<ContextPicker
open={scopeOpen}
onClose={() => setScopeOpen(false)}
scope={scope}
onChange={setScope}
decks={decks}
folders={folders}
/>
</div>
{settings.chatNotes === false && scope.kind !== "none" ? (
<p className="border-t border-[var(--hairline-soft)] bg-[var(--surface-sunken)] px-4 py-2 text-[11.5px] text-[var(--text-subtle)]">
{t("chat.notesOffNote")}{" "}
<Link href="/" className="underline decoration-dotted">
{t("chat.notesOffLink")}
</Link>
</p>
) : null}
</div>
);
}

View file

@ -0,0 +1,180 @@
"use client";
import { useEffect, useRef } from "react";
import { ChatIcon, LayersIcon, PaperclipIcon, SendIcon, StopIcon, XIcon } from "@/components/icons";
import { Spinner } from "@/components/ui";
import { useI18n } from "@/lib/i18n";
import { useIsPhone } from "@/lib/use-phone";
import type { ChatAttachment } from "@/lib/chat";
import { cn } from "@/lib/utils";
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.
*/
export function Composer({
value,
onChange,
onSend,
onStop,
busy,
online,
scopeLabel,
modelLabel,
onOpenScope,
onOpenModel,
onAttachFile,
attachBusy,
attachments,
onRemoveAttachment,
}: {
value: string;
onChange: (value: string) => void;
onSend: () => void;
onStop: () => void;
busy: boolean;
online: boolean;
scopeLabel: string;
modelLabel: string;
onOpenScope: () => void;
onOpenModel: () => void;
onAttachFile: (file: File) => void;
attachBusy: boolean;
attachments: PendingAttachment[];
onRemoveAttachment: (index: number) => void;
}) {
const { t } = useI18n();
const isPhone = useIsPhone();
const textarea = useRef<HTMLTextAreaElement>(null);
const fileInput = useRef<HTMLInputElement>(null);
useEffect(() => {
const element = textarea.current;
if (!element) return;
element.style.height = "auto";
element.style.height = `${Math.min(element.scrollHeight, 180)}px`;
}, [value]);
const canSend = online && !busy && value.trim().length > 0;
return (
<div className="border-t border-[var(--glass-border)] bg-[var(--glass-bg)] px-3 pb-safe pt-2 backdrop-blur-xl">
{attachments.length > 0 ? (
<ul className="mb-2 flex flex-wrap gap-1.5">
{attachments.map((entry, index) => (
<li
key={`${entry.attachment.name}-${index}`}
className="flex items-center gap-1.5 rounded-xl border border-[var(--hairline-soft)] bg-[var(--surface-sunken)] py-1 pl-2.5 pr-1 text-[12px] text-[var(--text-muted)]"
>
<span className="max-w-[160px] truncate">{entry.attachment.name}</span>
<span className="text-[var(--text-subtle)]">{entry.attachment.kind === "photo" ? t("chat.attachPhoto") : t("chat.attachDoc")}</span>
<button
type="button"
aria-label={t("chat.attachRemove")}
title={t("chat.attachRemove")}
onClick={() => onRemoveAttachment(index)}
className="grid size-6 place-items-center rounded-lg text-[var(--text-subtle)] transition hover:bg-[var(--hover)] hover:text-[var(--text)]"
>
<XIcon className="size-3.5" />
</button>
</li>
))}
</ul>
) : null}
<div className="flex items-end gap-2">
<button
type="button"
aria-label={t("chat.attach")}
title={t("chat.attach")}
disabled={attachBusy}
onClick={() => fileInput.current?.click()}
className="grid size-10 shrink-0 place-items-center rounded-2xl border border-[var(--glass-border)] bg-[var(--surface-strong)] text-[var(--text-muted)] transition hover:bg-[var(--hover)] hover:text-[var(--text)] disabled:opacity-60"
>
{attachBusy ? <Spinner className="size-4" /> : <PaperclipIcon className="size-5" />}
</button>
<input
ref={fileInput}
type="file"
accept="image/*,application/pdf,.docx,.pptx,.odt,.txt,.md,.csv"
className="sr-only"
onChange={(event) => {
const file = event.target.files?.[0];
if (file) onAttachFile(file);
event.target.value = "";
}}
/>
<textarea
ref={textarea}
value={value}
onChange={(event) => onChange(event.target.value)}
onKeyDown={(event) => {
if (event.key !== "Enter" || event.shiftKey || isPhone) return;
if (event.nativeEvent.isComposing) return;
event.preventDefault();
if (canSend) onSend();
}}
rows={1}
placeholder={t("chat.placeholder")}
aria-label={t("chat.placeholder")}
className="max-h-[180px] min-h-[44px] min-w-0 flex-1 resize-none rounded-2xl 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 placeholder:text-[var(--text-subtle)] focus:border-accent-400/60"
/>
{busy ? (
<button
type="button"
onClick={onStop}
aria-label={t("chat.stop")}
title={t("chat.stop")}
className="grid size-10 shrink-0 place-items-center rounded-2xl border border-[var(--glass-border)] bg-[var(--surface-strong)] text-[var(--text)] transition hover:bg-[var(--hover)]"
>
<StopIcon className="size-5" />
</button>
) : (
<button
type="button"
onClick={onSend}
disabled={!canSend}
aria-label={t("chat.send")}
title={t("chat.send")}
className={cn(
"grid size-10 shrink-0 place-items-center rounded-2xl transition",
canSend ? "grad-ice text-[#06111f]" : "border border-[var(--glass-border)] bg-[var(--surface-strong)] text-[var(--text-subtle)] opacity-60",
)}
>
<SendIcon className="size-5" />
</button>
)}
</div>
<div className="mt-2 flex items-center gap-2 overflow-x-auto">
<button
type="button"
onClick={onOpenScope}
className="flex min-w-0 items-center gap-1.5 rounded-xl border border-[var(--hairline-soft)] bg-[var(--surface-sunken)] px-2.5 py-1.5 text-[12px] font-medium text-[var(--text-muted)] transition hover:text-[var(--text)]"
>
<LayersIcon className="size-3.5 shrink-0" />
<span className="max-w-[170px] truncate">{scopeLabel}</span>
</button>
<button
type="button"
onClick={onOpenModel}
className="flex min-w-0 items-center gap-1.5 rounded-xl border border-[var(--hairline-soft)] bg-[var(--surface-sunken)] px-2.5 py-1.5 text-[12px] font-medium text-[var(--text-muted)] transition hover:text-[var(--text)]"
>
<ChatIcon className="size-3.5 shrink-0" />
<span className="max-w-[170px] truncate">{modelLabel}</span>
</button>
{!online ? (
<span role="status" className="ml-auto shrink-0 text-[11.5px] text-amber-600 dark:text-amber-300">
{t("chat.offlineNote")}
</span>
) : null}
</div>
</div>
);
}

View file

@ -0,0 +1,199 @@
"use client";
import { useEffect } from "react";
import { CheckCircleIcon, FolderIcon, LayersIcon } from "@/components/icons";
import { useI18n } from "@/lib/i18n";
import { useIsPhone } from "@/lib/use-phone";
import { deckHasNotes } from "@/lib/chat-context";
import type { ChatScope } from "@/lib/chat";
import type { Deck, Folder } from "@/lib/schemas";
import { cn } from "@/lib/utils";
/**
* Scope picker for the context chip: All notes · selected folders/decks ·
* None (general chat). Only what is selected here can ever travel as context.
*/
export function ContextPicker({
open,
onClose,
scope,
onChange,
decks,
folders,
}: {
open: boolean;
onClose: () => void;
scope: ChatScope;
onChange: (scope: ChatScope) => void;
decks: Deck[];
folders: Folder[];
}) {
const { t, plural } = useI18n();
const isPhone = useIsPhone();
useEffect(() => {
if (!open) return;
const onKey = (event: KeyboardEvent) => {
if (event.key === "Escape") onClose();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [open, onClose]);
if (!open) return null;
const selected = scope.kind === "decks" ? new Set(scope.deckIds) : new Set<string>();
const choose = (next: ChatScope) => {
onChange(next);
if (next.kind !== "decks") onClose();
};
const toggleDeck = (deckId: string) => {
const next = new Set(selected);
if (next.has(deckId)) next.delete(deckId);
else next.add(deckId);
onChange({ kind: "decks", deckIds: [...next] });
};
const toggleFolder = (folderId: string) => {
const inFolder = decks.filter((deck) => deck.folderId === folderId).map((deck) => deck.id);
const allSelected = inFolder.length > 0 && inFolder.every((id) => selected.has(id));
const next = new Set(selected);
for (const id of inFolder) {
if (allSelected) next.delete(id);
else next.add(id);
}
onChange({ kind: "decks", deckIds: [...next] });
};
const modeRow = (
value: ChatScope["kind"],
title: string,
note: string,
icon: React.ReactNode,
) => (
<button
type="button"
aria-pressed={scope.kind === value}
onClick={() =>
choose(
value === "all"
? { kind: "all" }
: value === "none"
? { kind: "none" }
: { kind: "decks", deckIds: selected.size > 0 ? [...selected] : decks.map((deck) => deck.id) },
)
}
className={cn(
"flex w-full items-center gap-3 rounded-2xl border px-3 py-3 text-left transition",
scope.kind === value
? "border-[var(--nav-active-border)] bg-[var(--nav-active-bg)]"
: "border-[var(--hairline-soft)] bg-[var(--surface-bg)] hover:bg-[var(--hover)]",
)}
>
<span className="shrink-0 text-[var(--text-muted)]">{icon}</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>
{scope.kind === value ? <CheckCircleIcon className="size-5 shrink-0 text-accent-500" /> : null}
</button>
);
const body = (
<div className="space-y-2">
{modeRow("all", t("chat.scope.all"), t("chat.scope.allNote"), <LayersIcon className="size-5" />)}
{modeRow("decks", t("chat.scope.pick"), t("chat.scope.pickNote"), <FolderIcon className="size-5" />)}
{scope.kind === "decks" ? (
<div className="max-h-[38dvh] space-y-1 overflow-y-auto overscroll-contain rounded-2xl border border-[var(--hairline-soft)] bg-[var(--surface-sunken)] p-2">
{folders.map((folder) => {
const inFolder = decks.filter((deck) => deck.folderId === folder.id);
if (inFolder.length === 0) return null;
const allSelected = inFolder.every((deck) => selected.has(deck.id));
return (
<button
key={folder.id}
type="button"
onClick={() => toggleFolder(folder.id)}
className="flex w-full items-center gap-2 rounded-xl px-2 py-2 text-left text-[12.5px] font-semibold text-[var(--text-muted)] transition hover:bg-[var(--hover)]"
>
<span
className={cn(
"grid size-4 place-items-center rounded-[0.35rem] border",
allSelected ? "border-accent-500 bg-accent-500 text-white" : "border-[var(--hairline)]",
)}
aria-hidden
>
{allSelected ? <CheckCircleIcon className="size-3" /> : null}
</span>
<FolderIcon className="size-3.5" />
<span className="truncate">{folder.name}</span>
<span className="ml-auto text-[11px] font-normal text-[var(--text-subtle)]">
{plural(inFolder.length, "chat.scope.deckCount")}
</span>
</button>
);
})}
{decks.map((deck) => {
const active = selected.has(deck.id);
return (
<button
key={deck.id}
type="button"
onClick={() => toggleDeck(deck.id)}
className="flex w-full items-center gap-2 rounded-xl px-2 py-2 text-left transition hover:bg-[var(--hover)]"
>
<span
className={cn(
"grid size-4 shrink-0 place-items-center rounded-[0.35rem] border",
active ? "border-accent-500 bg-accent-500 text-white" : "border-[var(--hairline)]",
)}
aria-hidden
>
{active ? <CheckCircleIcon className="size-3" /> : null}
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-[13px] text-[var(--text)]">{deck.title}</span>
<span className="block text-[11px] text-[var(--text-subtle)]">
{deckHasNotes(deck) ? t("chat.scope.hasNotes") : t("chat.scope.generatedOnly")}
</span>
</span>
</button>
);
})}
</div>
) : null}
{modeRow("none", t("chat.scope.none"), t("chat.scope.noneNote"), <span className="text-[18px]">💬</span>)}
</div>
);
if (isPhone) {
return (
<div className="fixed inset-0 z-50" role="dialog" aria-modal="true" aria-label={t("chat.scope.title")}>
<button type="button" className="absolute inset-0 bg-black/45" aria-label={t("common.close")} onClick={onClose} />
<div className="glass-panel absolute inset-x-0 bottom-0 max-h-[85dvh] overflow-y-auto rounded-b-none px-4 pb-safe pt-2">
<div className="sheet-grabber mx-auto" aria-hidden />
<h2 className="px-1 pb-3 pt-2 text-[15px] font-semibold text-[var(--text)]">{t("chat.scope.title")}</h2>
{body}
<p className="px-1 py-3 text-[11.5px] text-[var(--text-subtle)]">{t("chat.scope.note")}</p>
</div>
</div>
);
}
return (
<div
className="glass-panel absolute bottom-full left-0 z-40 mb-2 w-96 p-3"
role="dialog"
aria-label={t("chat.scope.title")}
>
<h2 className="px-1 pb-2 text-[13px] font-semibold uppercase tracking-wide text-[var(--text-subtle)]">
{t("chat.scope.title")}
</h2>
{body}
<p className="px-1 pt-2 text-[11.5px] text-[var(--text-subtle)]">{t("chat.scope.note")}</p>
</div>
);
}

View file

@ -0,0 +1,39 @@
"use client";
import { useState } from "react";
import { CheckCircleIcon, CopyIcon, XCircleIcon } from "@/components/icons";
import { copyText } from "@/lib/clipboard";
import { useI18n } from "@/lib/i18n";
import { cn } from "@/lib/utils";
/** Small square copy control used by chat message actions and code blocks. */
export function CopyIconButton({ text, className }: { text: string; className?: string }) {
const { t } = useI18n();
const [state, setState] = useState<"idle" | "copied" | "failed">("idle");
return (
<button
type="button"
aria-label={t("chat.copy")}
title={state === "copied" ? t("chat.copied") : state === "failed" ? t("chat.copyFailed") : t("chat.copy")}
onClick={async () => {
const ok = await copyText(text);
setState(ok ? "copied" : "failed");
window.setTimeout(() => setState("idle"), 1800);
}}
className={cn(
"grid size-9 shrink-0 place-items-center rounded-xl border border-[var(--glass-border)] bg-[var(--surface-strong)] text-[var(--text-muted)] transition hover:bg-[var(--hover)] hover:text-[var(--text)]",
className,
)}
>
{state === "copied" ? (
<CheckCircleIcon className="size-4 text-emerald-500" />
) : state === "failed" ? (
<XCircleIcon className="size-4 text-rose-500" />
) : (
<CopyIcon className="size-4" />
)}
</button>
);
}

View file

@ -0,0 +1,316 @@
"use client";
import { memo, useMemo } from "react";
import { CopyIconButton } from "./CopyIconButton";
import { MathSpan } from "./MathSpan";
/**
* Safe, dependency-free markdown for chat answers: lists, tables, code blocks
* (with a copy button), quotes, headings, inline code/bold/italic/links and
* $...$ math. It parses to React nodes — nothing is ever set as HTML.
*/
type Block =
| { type: "p"; lines: string[] }
| { type: "heading"; level: number; text: string }
| { type: "list"; ordered: boolean; items: { text: string; depth: number }[] }
| { type: "code"; lang: string; code: string }
| { type: "table"; head: string[]; rows: string[][] }
| { type: "quote"; lines: string[] }
| { type: "math"; tex: string }
| { type: "hr" };
const FENCE = /^(```|~~~)\s*([\w+-]*)\s*$/;
const HEADING = /^(#{1,6})\s+(.*)$/;
const LIST_ITEM = /^(\s*)([-*+]|\d+[.)])\s+(.*)$/;
const QUOTE = /^>\s?(.*)$/;
const HR = /^\s*([-*_])\1{2,}\s*$/;
function isTableSeparator(line: string): boolean {
return /^\s*\|?[\s:|-]*-[\s:|-]*\|?\s*$/.test(line) && line.includes("-");
}
function splitTableRow(line: string): string[] {
const trimmed = line.trim().replace(/^\|/, "").replace(/\|$/, "");
return trimmed.split("|").map((cell) => cell.trim());
}
export function parseBlocks(text: string): Block[] {
const blocks: Block[] = [];
const lines = text.replace(/\r\n/g, "\n").split("\n");
let index = 0;
const flushParagraph = (collected: string[]) => {
const joined = collected.join("\n").trim();
if (joined) blocks.push({ type: "p", lines: collected });
};
let paragraph: string[] = [];
while (index < lines.length) {
const line = lines[index];
const fence = FENCE.exec(line);
if (fence) {
flushParagraph(paragraph);
paragraph = [];
const lang = fence[2] ?? "";
const code: string[] = [];
index += 1;
while (index < lines.length && !FENCE.test(lines[index])) {
code.push(lines[index]);
index += 1;
}
index += 1; // closing fence (or end of stream)
blocks.push({ type: "code", lang, code: code.join("\n") });
continue;
}
if (HR.test(line)) {
flushParagraph(paragraph);
paragraph = [];
blocks.push({ type: "hr" });
index += 1;
continue;
}
const heading = HEADING.exec(line);
if (heading) {
flushParagraph(paragraph);
paragraph = [];
blocks.push({ type: "heading", level: heading[1].length, text: heading[2].trim() });
index += 1;
continue;
}
// $$ ... $$ on its own line(s)
if (line.trim().startsWith("$$")) {
flushParagraph(paragraph);
paragraph = [];
const trimmed = line.trim();
if (trimmed.length > 4 && trimmed.endsWith("$$")) {
blocks.push({ type: "math", tex: trimmed.slice(2, -2).trim() });
index += 1;
continue;
}
const math: string[] = [trimmed.slice(2)];
index += 1;
while (index < lines.length && !lines[index].trim().endsWith("$$")) {
math.push(lines[index]);
index += 1;
}
if (index < lines.length) {
math.push(lines[index].trim().slice(0, -2));
index += 1;
}
blocks.push({ type: "math", tex: math.join("\n").trim() });
continue;
}
const quote = QUOTE.exec(line);
if (quote) {
flushParagraph(paragraph);
paragraph = [];
const collected = [quote[1]];
index += 1;
while (index < lines.length) {
const next = QUOTE.exec(lines[index]);
if (!next) break;
collected.push(next[1]);
index += 1;
}
blocks.push({ type: "quote", lines: collected });
continue;
}
const item = LIST_ITEM.exec(line);
if (item) {
flushParagraph(paragraph);
paragraph = [];
const ordered = /\d/.test(item[2]);
const items: { text: string; depth: number }[] = [];
while (index < lines.length) {
const next = LIST_ITEM.exec(lines[index]);
if (!next) break;
const depth = Math.min(2, Math.floor(next[1].replace(/\t/g, " ").length / 2));
items.push({ text: next[3], depth });
index += 1;
}
blocks.push({ type: "list", ordered, items });
continue;
}
// Table: header row followed by a separator row.
if (line.includes("|") && index + 1 < lines.length && isTableSeparator(lines[index + 1])) {
flushParagraph(paragraph);
paragraph = [];
const head = splitTableRow(line);
index += 2;
const rows: string[][] = [];
while (index < lines.length && lines[index].includes("|") && lines[index].trim()) {
rows.push(splitTableRow(lines[index]));
index += 1;
}
blocks.push({ type: "table", head, rows });
continue;
}
if (!line.trim()) {
flushParagraph(paragraph);
paragraph = [];
index += 1;
continue;
}
paragraph.push(line);
index += 1;
}
flushParagraph(paragraph);
return blocks;
}
const INLINE =
/(\*\*[^*]+\*\*|__[^_]+__|`[^`]+`|\[[^\]]+\]\([^)\s]+\)|\$\$[^$]+\$\$|\$[^$\n]+\$|\*[^*\n]+\*|_[^_\n]+_)/g;
function inlineNodes(text: string, keyPrefix: string): React.ReactNode[] {
return text.split(INLINE).map((part, index) => {
const key = `${keyPrefix}-${index}`;
if (!part) return null;
if (part.startsWith("**") && part.endsWith("**") && part.length > 4) {
return <strong key={key}>{part.slice(2, -2)}</strong>;
}
if (part.startsWith("__") && part.endsWith("__") && part.length > 4) {
return <strong key={key}>{part.slice(2, -2)}</strong>;
}
if (part.startsWith("`") && part.endsWith("`") && part.length > 2) {
return (
<code key={key} className="rounded-md bg-[var(--surface-sunken)] px-1.5 py-0.5 font-mono text-[0.85em]">
{part.slice(1, -1)}
</code>
);
}
if (part.startsWith("$$") && part.endsWith("$$") && part.length > 4) {
return <MathSpan key={key} tex={part.slice(2, -2).trim()} display />;
}
if (part.startsWith("$") && part.endsWith("$") && part.length > 2) {
return <MathSpan key={key} tex={part.slice(1, -1)} />;
}
const link = /^\[([^\]]+)\]\(([^)\s]+)\)$/.exec(part);
if (link) {
const href = link[2];
if (/^https?:\/\//i.test(href)) {
return (
<a
key={key}
href={href}
target="_blank"
rel="noopener noreferrer"
className="font-medium text-accent-600 underline decoration-accent-400/50 underline-offset-2 dark:text-accent-300"
>
{link[1]}
</a>
);
}
return <span key={key}>{link[1]}</span>;
}
if (part.startsWith("*") && part.endsWith("*") && part.length > 2) {
return <em key={key}>{part.slice(1, -1)}</em>;
}
if (part.startsWith("_") && part.endsWith("_") && part.length > 2) {
return <em key={key}>{part.slice(1, -1)}</em>;
}
return <span key={key}>{part}</span>;
});
}
function renderBlock(block: Block, index: number) {
const key = `b-${index}`;
switch (block.type) {
case "heading": {
const Tag = (block.level <= 2 ? "h3" : "h4") as "h3" | "h4";
return (
<Tag key={key} className="text-[15px] font-semibold text-[var(--text)]">
{inlineNodes(block.text, key)}
</Tag>
);
}
case "list": {
const Tag = block.ordered ? "ol" : "ul";
return (
<Tag key={key} className={block.ordered ? "list-decimal space-y-1 pl-5" : "list-disc space-y-1 pl-5"}>
{block.items.map((item, itemIndex) => (
<li
key={`${key}-${itemIndex}`}
className={item.depth > 0 ? "ml-4 list-[circle]" : undefined}
>
{inlineNodes(item.text, `${key}-${itemIndex}`)}
</li>
))}
</Tag>
);
}
case "code":
return (
<div key={key} className="group relative">
<pre className="overflow-x-auto rounded-xl border border-[var(--hairline-soft)] bg-[var(--surface-sunken)] p-3 text-[12.5px] leading-relaxed">
<code className="font-mono">{block.code}</code>
</pre>
<div className="absolute right-2 top-2 opacity-90">
<CopyIconButton text={block.code} />
</div>
</div>
);
case "table":
return (
<div key={key} className="overflow-x-auto">
<table className="w-full min-w-[280px] border-collapse text-[13px]">
<thead>
<tr>
{block.head.map((cell, cellIndex) => (
<th
key={`${key}-h-${cellIndex}`}
className="border-b border-[var(--hairline)] px-2 py-1.5 text-left font-semibold text-[var(--text)]"
>
{inlineNodes(cell, `${key}-h-${cellIndex}`)}
</th>
))}
</tr>
</thead>
<tbody>
{block.rows.map((row, rowIndex) => (
<tr key={`${key}-r-${rowIndex}`}>
{block.head.map((_, cellIndex) => (
<td key={`${key}-r-${rowIndex}-${cellIndex}`} className="border-b border-[var(--hairline-soft)] px-2 py-1.5 align-top">
{inlineNodes(row[cellIndex] ?? "", `${key}-r-${rowIndex}-${cellIndex}`)}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
);
case "quote":
return (
<blockquote key={key} className="border-l-2 border-[var(--hairline)] pl-3 text-[var(--text-muted)]">
{inlineNodes(block.lines.join("\n"), key)}
</blockquote>
);
case "math":
return <MathSpan key={key} tex={block.tex} display />;
case "hr":
return <hr key={key} className="border-[var(--hairline-soft)]" />;
default:
return (
<p key={key} className="whitespace-pre-wrap break-words">
{inlineNodes(block.lines.join("\n"), key)}
</p>
);
}
}
export const Markdown = memo(function Markdown({ text }: { text: string }) {
const blocks = useMemo(() => parseBlocks(text), [text]);
return <div className="chat-md space-y-3 text-[15px] leading-relaxed text-[var(--text)]">{blocks.map(renderBlock)}</div>;
});

View file

@ -0,0 +1,41 @@
"use client";
import { useEffect, useRef } from "react";
/**
* Math rendering, loaded only when a message actually contains math: KaTeX is
* imported dynamically (its CSS with it) the first time a span mounts and
* writes into this span imperatively — no HTML injection anywhere.
*/
export function MathSpan({ tex, display = false }: { tex: string; display?: boolean }) {
const ref = useRef<HTMLSpanElement>(null);
useEffect(() => {
let cancelled = false;
void (async () => {
try {
const [katex] = await Promise.all([import("katex"), import("katex/dist/katex.min.css")]);
if (cancelled || !ref.current) return;
katex.default.render(tex, ref.current, {
displayMode: display,
throwOnError: false,
strict: false,
trust: false,
});
} catch {
if (!cancelled && ref.current) ref.current.textContent = tex;
}
})();
return () => {
cancelled = true;
};
}, [tex, display]);
return (
<span
ref={ref}
className={display ? "my-2 block overflow-x-auto text-center" : "inline-block max-w-full align-middle"}
aria-label={tex}
/>
);
}

View file

@ -0,0 +1,132 @@
"use client";
import Link from "next/link";
import { CopyIconButton } from "./CopyIconButton";
import { Markdown } from "./Markdown";
import { ThinkingRow } from "./ThinkingRow";
import { Button } from "@/components/ui";
import { ModelErrorActions } from "@/components/ModelErrorActions";
import { PencilIcon, RefreshIcon } from "@/components/icons";
import { useI18n } from "@/lib/i18n";
import type { ChatMessage } from "@/lib/chat";
/**
* One turn: user bubbles on the right, assistant answers as full-width text
* (no bubble) with the Thinking row, sources, actions and error recovery.
*/
export function MessageRow({
message,
streaming,
isLastAssistant,
isLastUser,
busy,
onRegenerate,
onEdit,
onRetry,
}: {
message: ChatMessage;
streaming?: boolean;
isLastAssistant?: boolean;
isLastUser?: boolean;
busy?: boolean;
onRegenerate?: () => void;
onEdit?: () => void;
onRetry?: () => void;
}) {
const { t } = useI18n();
if (message.role === "user") {
return (
<div className="flex flex-col items-end gap-1">
<div className="max-w-[88%] rounded-3xl rounded-br-lg border border-[var(--glass-border)] bg-[var(--surface-strong)] px-3.5 py-2.5">
<p className="whitespace-pre-wrap break-words text-[15px] leading-relaxed text-[var(--text)]">{message.content}</p>
{message.attachments && message.attachments.length > 0 ? (
<p className="mt-1.5 text-[11.5px] text-[var(--text-subtle)]">
{message.attachments.map((attachment) => attachment.name).join(" · ")}
</p>
) : null}
</div>
{isLastUser && !busy && onEdit ? (
<button
type="button"
onClick={onEdit}
className="flex items-center gap-1 rounded-lg px-1.5 py-1 text-[11.5px] text-[var(--text-subtle)] transition hover:text-[var(--text)]"
>
<PencilIcon className="size-3.5" />
{t("chat.edit")}
</button>
) : null}
</div>
);
}
return (
<div className="space-y-2">
{message.thinking ? <ThinkingRow thinking={message.thinking} streaming={streaming && !message.content} /> : null}
{message.content ? (
<Markdown text={message.content} />
) : streaming ? (
<span className="flex items-center gap-1 py-1" role="status" aria-label={t("chat.thinking")}>
{[0, 1, 2].map((dot) => (
<span
key={dot}
className="size-1.5 animate-bounce rounded-full bg-[var(--text-subtle)] motion-reduce:animate-none motion-reduce:opacity-60"
style={{ animationDelay: `${dot * 120}ms` }}
/>
))}
</span>
) : null}
{message.error ? (
<div
role="alert"
className="rounded-2xl border border-rose-300/60 bg-rose-50/70 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">{message.error.message}</p>
<div className="mt-2 flex flex-wrap items-center gap-2">
{onRetry ? (
<Button size="sm" variant="secondary" onClick={onRetry}>
<RefreshIcon className="size-4" /> {t("chat.retry")}
</Button>
) : null}
<ModelErrorActions kind={message.error.kind} />
</div>
</div>
) : null}
{!streaming && message.content && !message.error ? (
<div className="flex flex-wrap items-center gap-2">
<CopyIconButton text={message.content} />
{isLastAssistant && !busy && onRegenerate ? (
<button
type="button"
onClick={onRegenerate}
className="flex items-center gap-1.5 rounded-xl border border-[var(--glass-border)] bg-[var(--surface-strong)] px-2.5 py-2 text-[12px] font-medium text-[var(--text-muted)] transition hover:bg-[var(--hover)] hover:text-[var(--text)]"
>
<RefreshIcon className="size-3.5" />
{t("chat.regenerate")}
</button>
) : null}
{message.model ? <span className="text-[11px] text-[var(--text-subtle)]">{message.model}</span> : null}
</div>
) : null}
{message.sources && message.sources.length > 0 && !streaming ? (
<div className="flex flex-wrap items-center gap-1.5">
<span className="text-[11px] uppercase tracking-wide text-[var(--text-subtle)]">{t("chat.sources")}</span>
{message.sources.slice(0, 5).map((source, index) => (
<Link
key={`${source.deckId}-${source.section}-${index}`}
href={`/deck/${source.deckId}`}
className="max-w-[220px] truncate rounded-lg border border-[var(--hairline-soft)] bg-[var(--surface-sunken)] px-2 py-1 text-[11.5px] text-[var(--text-muted)] transition hover:text-[var(--text)]"
title={`${source.deckTitle} — ${source.section}`}
>
{source.deckTitle} · {source.section}
</Link>
))}
</div>
) : null}
</div>
);
}

View file

@ -0,0 +1,115 @@
"use client";
import { useEffect } from "react";
import { CheckCircleIcon } from "@/components/icons";
import { useI18n } from "@/lib/i18n";
import { useIsPhone } from "@/lib/use-phone";
import type { ChatModelChoice } from "@/lib/chat";
import { cn } from "@/lib/utils";
/**
* Model chip picker: a bottom sheet on phones, a small dropdown on desktop.
* The list is the server's CHAT_MODELS allowlist (from the health check) with
* text/vision badges and availability; picking one remembers it.
*/
export function ModelPicker({
open,
onClose,
models,
value,
onChange,
}: {
open: boolean;
onClose: () => void;
models: ChatModelChoice[];
value: string;
onChange: (model: string) => void;
}) {
const { t } = useI18n();
const isPhone = useIsPhone();
useEffect(() => {
if (!open) return;
const onKey = (event: KeyboardEvent) => {
if (event.key === "Escape") onClose();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [open, onClose]);
if (!open) return null;
const badge = (model: ChatModelChoice) =>
model.badge === "vision" ? t("chat.modelVision") : model.badge === "both" ? t("chat.modelBoth") : t("chat.modelText");
const rows = (
<ul className="max-h-[60dvh] space-y-1 overflow-y-auto overscroll-contain">
{models.map((model) => {
const selected = model.name === value;
const unavailable = model.available === false;
return (
<li key={model.name}>
<button
type="button"
disabled={unavailable}
aria-pressed={selected}
onClick={() => {
onChange(model.name);
onClose();
}}
className={cn(
"flex w-full items-center gap-3 rounded-2xl border px-3 py-3 text-left transition",
selected
? "border-[var(--nav-active-border)] bg-[var(--nav-active-bg)]"
: "border-[var(--hairline-soft)] bg-[var(--surface-bg)] hover:bg-[var(--hover)]",
unavailable && "opacity-55",
)}
>
<span className="min-w-0 flex-1">
<span className="block truncate text-[13.5px] font-semibold text-[var(--text)]">{model.name}</span>
<span className="mt-0.5 flex items-center gap-2 text-[11.5px] text-[var(--text-subtle)]">
<span className="rounded-md border border-[var(--hairline-soft)] px-1.5 py-0.5">{badge(model)}</span>
{model.available === true ? (
<span className="text-emerald-600 dark:text-emerald-300">{t("chat.modelAvailable")}</span>
) : model.available === false ? (
<span className="text-amber-600 dark:text-amber-300">{t("chat.modelUnavailable")}</span>
) : null}
</span>
</span>
{selected ? <CheckCircleIcon className="size-5 shrink-0 text-accent-500" /> : null}
</button>
</li>
);
})}
</ul>
);
if (isPhone) {
return (
<div className="fixed inset-0 z-50" role="dialog" aria-modal="true" aria-label={t("chat.modelTitle")}>
<button type="button" className="absolute inset-0 bg-black/45" aria-label={t("common.close")} onClick={onClose} />
<div className="glass-panel absolute inset-x-0 bottom-0 max-h-[80dvh] rounded-b-none px-4 pb-safe pt-2">
<div className="sheet-grabber mx-auto" aria-hidden />
<h2 className="px-1 pb-3 pt-2 text-[15px] font-semibold text-[var(--text)]">{t("chat.modelTitle")}</h2>
{rows}
<p className="px-1 py-3 text-[11.5px] text-[var(--text-subtle)]">{t("chat.modelNote")}</p>
</div>
</div>
);
}
return (
<div
className="glass-panel absolute bottom-full right-0 z-40 mb-2 w-80 p-3"
role="dialog"
aria-label={t("chat.modelTitle")}
>
<h2 className="px-1 pb-2 text-[13px] font-semibold uppercase tracking-wide text-[var(--text-subtle)]">
{t("chat.modelTitle")}
</h2>
{rows}
<p className="px-1 pt-2 text-[11.5px] text-[var(--text-subtle)]">{t("chat.modelNote")}</p>
</div>
);
}

View file

@ -0,0 +1,36 @@
"use client";
import { useState } from "react";
import { ChevronDownIcon, SparklesIcon } from "@/components/icons";
import { useI18n } from "@/lib/i18n";
import { cn } from "@/lib/utils";
/**
* Reasoning models stream `thinking` separately from the answer. It is hidden
* by default behind this row — the answer itself is never the raw reasoning.
*/
export function ThinkingRow({ thinking, streaming }: { thinking: string; streaming?: boolean }) {
const { t } = useI18n();
const [open, setOpen] = useState(false);
return (
<div className="rounded-2xl border border-[var(--hairline-soft)] bg-[var(--surface-sunken)]">
<button
type="button"
onClick={() => setOpen((value) => !value)}
aria-expanded={open}
className="flex w-full items-center gap-2 px-3 py-2 text-left text-[12.5px] font-medium text-[var(--text-muted)] transition hover:text-[var(--text)]"
>
<SparklesIcon className={cn("size-3.5 shrink-0", streaming && "animate-pulse")} />
<span>{streaming ? t("chat.thinking") : t("chat.thought")}</span>
<ChevronDownIcon className={cn("ml-auto size-4 transition-transform", open && "rotate-180")} />
</button>
{open ? (
<div className="whitespace-pre-wrap break-words px-3 pb-3 text-[12.5px] leading-relaxed text-[var(--text-subtle)]">
{thinking}
</div>
) : null}
</div>
);
}