Chat mode, phase 2: local history, answer actions, motion polish

- Conversations in a versioned IndexedDB store (freezmo-chat v1, zod
  validated with the same schema backup/sync use, capped at 100 x 300).
  Settled turns persist (question always; answer when produced, partial
  stops included); ?c=<id> keeps reloads on the conversation via
  history.replaceState (no router round-trip mid-chat).
- History UI: desktop column, phone sheet (spring, backdrop, Escape),
  auto titles from the first question, inline rename, delete with an
  Undo toast, diacritic-folding search over titles and message text.
  Scope and model are remembered per conversation; history is readable
  offline.
- Assistant actions: Turn into flashcards (New deck with the answer as
  notes, landing on the options step - deck seed gained step) and Save
  to notes (appends to the single scoped deck, trimmed at 40k).
- Chats travel in the backup export (preview counts them) and in the
  encrypted sync payload; still never the key.
- Motion from lib/motion.ts: message fade-up, sheet springs for the
  model/context/history sheets, 150ms composer height transition, all
  skipped under reduced motion.
- Tests: chat suite 63 -> 89 checks (history, reload, rename/search/
  delete/undo, actions, export, two-device sync round-trip, offline,
  desktop panel). Full set: 12/12 suites, 513 checks. SW -> freezmo-v33.

Also: tests/model-compare.mjs benchmark (diagnostic) for deciding the
default text model on numbers; defaults unchanged.
This commit is contained in:
Martin Dvořák 2026-10-11 19:03:48 +00:00
commit 5ca0de920c
19 changed files with 1107 additions and 113 deletions

View file

@ -711,6 +711,14 @@ have a step explained without leaving the app.
allowlist and never accepts a model name from the client.
- **Attachments.** Photos, PDFs and documents go through the existing vision/extraction step first
(plain text), so any chat model works — text-only included.
- **History.** Conversations live in a local IndexedDB store: a list on the left on desktop, a
sheet on phones, with auto titles from your first question, rename, delete (with Undo) and a
search across titles and message text. Conversations remember their scope and model, reopen from
`?c=<id>` (so a reload continues where you left off), stay readable offline, and are included in
the backup export and the encrypted sync — still never the key.
- **From an answer.** Every answer can become a deck (**Turn into flashcards** opens New deck with
the answer as notes, on the options step) or be appended to the scoped deck's notes (**Save to
notes**, only offered when exactly one deck is in scope).
- **Streaming and controls.** Tokens stream with a caret and a stop button; auto-scroll pauses when
you scroll up and a jump-to-bottom button appears; answers render safe markdown (lists, tables,
code blocks with copy — no HTML injection) and KaTeX loads dynamically only when a message
@ -1136,7 +1144,7 @@ Things I chose while building, with the reasoning:
limits, the learning engine, homework check/follow-ups). Suites that need a stub Ollama or
VAPID keys start their own `next start` instance on a spare port, verify the port is free and
kill the process group on exit; test scratch stays in `tests/.tmp/` (gitignored). Twelve suites,
487 checks.
513 checks.
46. **The ice orbs are rising bubbles now, composited and cheaper to read through.** The glossy
orbs (the glacier blobs stay exactly as they were) rise from below the bottom edge to above the
@ -1183,6 +1191,24 @@ Things I chose while building, with the reasoning:
points, streaming, stop, markdown + KaTeX, the model and scope pickers, edit/regenerate,
offline, attachments and reduced motion).
48. **Chat history is local, bounded and part of the backup/sync payload.** Conversations live in
their own versioned IndexedDB (`freezmo-chat` v1, store `chats`), validated with the same zod
schema the backup and sync use (`chatConversationSchema`), capped at 100 conversations × 300
messages, with the in-memory mirror the other stores use. Every settled turn is persisted (the
question always, the answer when one was produced — partial answers from a stop included); the
URL carries `?c=<id>` so a reload continues the conversation, and that URL tweak uses
`history.replaceState` so it never triggers a router round-trip mid-chat. The list (desktop
column, phone sheet) has auto titles from the first question, inline rename, delete with an Undo
toast, and diacritic-folding search over titles and message text. Chats ride along in the backup
file (the import preview counts them) and in the encrypted sync payload — still never the key.
Assistant answers also offer **Turn into flashcards** (opens New deck with the answer as notes,
landing on the options step) and **Save to notes** (appends to the single scoped deck, trimming
at 40k characters). Motion comes from `lib/motion.ts`: message fade-up, sheet springs, a 150 ms
composer height transition — all skipped under reduced motion. Verified by the chat suite (89
checks: everything from phase 1 plus history persistence, reload, rename/search/delete/undo,
both actions, the export payload, a two-device sync round-trip of a chat, offline readability
and the desktop panel).
36. **The mobile declutter pass reshapes, it doesn't fork.** Phones (≤639px) get a 3-step deck
flow and a 2-step homework flow that share the exact state and handlers with the desktop
cards (one `UploadScreen` / one `HomeworkInput`, two layouts switched by `useIsPhone()`);

View file

@ -92,6 +92,7 @@ export function DataSection() {
decks: pending.preview.decks.added + pending.preview.decks.replaced,
homework: pending.preview.homework.added + pending.preview.homework.replaced,
folders: pending.preview.folders.added + pending.preview.folders.replaced,
chats: pending.preview.chats.added + pending.preview.chats.replaced,
})}
</p>
<p className="text-[11px] leading-relaxed text-[var(--text-subtle)]">{t("settings.importMergeNote")}</p>

View file

@ -328,6 +328,7 @@ export function UploadScreen() {
setText(seed.text);
if (seed.difficulty) setDifficulty(seed.difficulty);
if (seed.count) setCount(seed.count);
if (seed.step && seed.step > 1) setStep(seed.step);
}, []);
/* Persist the draft while the user works (debounced, best-effort). */

View file

@ -7,9 +7,10 @@ import Link from "next/link";
import { Composer, type PendingAttachment } from "./Composer";
import { ContextPicker } from "./ContextPicker";
import { ChatHistoryList, HistorySheet } from "./HistoryPanel";
import { MessageRow } from "./MessageRow";
import { ModelPicker } from "./ModelPicker";
import { ChatIcon, ChevronDownIcon, ChevronLeftIcon, PlusIcon, RefreshIcon } from "@/components/icons";
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";
@ -17,24 +18,30 @@ 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 { 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 } from "@/lib/storage";
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 };
@ -71,6 +78,16 @@ export function ChatScreen() {
const [scopeOpen, setScopeOpen] = useState(false);
const [modelOpen, setModelOpen] = useState(false);
const [error, setError] = useState<{ message: string; kind?: string } | null>(null);
const [conversationId, setConversationId] = useState<string | null>(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<AbortController | null>(null);
const scrollRef = useRef<HTMLDivElement>(null);
@ -89,6 +106,33 @@ export function ChatScreen() {
setModel(readStoredChatModel() ?? "");
}, []);
/* History: hydrate once, then open ?c=<id> conversations. */
useEffect(() => {
void ensureChatsHydrated();
}, []);
const loadedRef = useRef<string | null>(null);
const loadConversation = useCallback((found: ChatConversation) => {
setConversationId(found.id);
setTitle(found.title);
setMessages(conversationMessages(found));
setScope(found.scope);
setSeed(null);
setAttachments([]);
setDraft("");
setError(null);
if (found.model) setModel(found.model);
loadedRef.current = 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;
@ -191,6 +235,33 @@ export function ChatScreen() {
return chunks;
};
const persist = useCallback(
(nextMessages: ChatMessage[], options: { title?: string; model?: string; scope?: ChatScope } = {}) => {
const id = conversationId ?? chatMessageId("chat");
if (!conversationId) {
setConversationId(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({
@ -211,6 +282,10 @@ export function ChatScreen() {
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({
@ -220,6 +295,7 @@ export function ChatScreen() {
signal: controller.signal,
onToken: (text) => {
produced = true;
finalContent += text;
setMessages((previous) =>
previous.map((message) =>
message.id === assistantId ? { ...message, content: message.content + text } : message,
@ -227,6 +303,7 @@ export function ChatScreen() {
);
},
onThinking: (text) => {
finalThinking += text;
setMessages((previous) =>
previous.map((message) =>
message.id === assistantId ? { ...message, thinking: (message.thinking ?? "") + text } : message,
@ -234,6 +311,7 @@ export function ChatScreen() {
);
},
onDone: (modelName) => {
finalModel = modelName;
setMessages((previous) =>
previous.map((message) => (message.id === assistantId ? { ...message, model: modelName } : message)),
);
@ -244,7 +322,8 @@ export function ChatScreen() {
if (apiError.kind === "aborted") {
// Stopped by the user: keep whatever streamed in.
} else {
setError({ message: apiError.message, kind: apiError.kind });
failure = { message: apiError.message, kind: apiError.kind };
setError(failure);
if (!produced) {
// Nothing arrived: take the placeholder away and give the text back.
setMessages(history);
@ -253,7 +332,7 @@ export function ChatScreen() {
setMessages((previous) =>
previous.map((message) =>
message.id === assistantId
? { ...message, error: { message: apiError.message, kind: apiError.kind } }
? { ...message, error: failure ?? undefined }
: message,
),
);
@ -262,6 +341,21 @@ export function ChatScreen() {
} 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);
}
};
@ -311,10 +405,70 @@ export function ChatScreen() {
const newChat = () => {
abortRef.current?.abort();
loadedRef.current = null;
setConversationId(null);
setTitle("");
setMessages([]);
setSeed(null);
setAttachments([]);
setDraft("");
setError(null);
setHistoryOpen(false);
window.history.replaceState(null, "", "/chat");
};
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) => {
@ -346,7 +500,25 @@ export function ChatScreen() {
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)" }}>
<div className="flex h-full min-h-0 w-full">
{/* Desktop: the conversation list lives in its own column. */}
<aside className="hidden w-64 shrink-0 flex-col border-r border-[var(--hairline-soft)] bg-[var(--surface-sunken)] p-2 lg:flex">
<h2 className="px-1 pb-1 pt-1 text-[12px] font-semibold uppercase tracking-wide text-[var(--text-subtle)]">
{t("chat.history")}
</h2>
<ChatHistoryList
conversations={conversations}
currentId={conversationId}
query={query}
onQueryChange={setQuery}
onOpen={openConversation}
onRename={renameConversation}
onDelete={handleDeleteConversation}
onNew={newChat}
/>
</aside>
<div className="relative flex h-full min-h-0 min-w-0 flex-1 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"
@ -358,12 +530,21 @@ export function ChatScreen() {
<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>
<h1 className="truncate text-[15px] font-semibold text-[var(--text)]">{title || t("chat.title")}</h1>
<p className="truncate text-[11.5px] text-[var(--text-subtle)]">
{scopeText}
{seed ? ` · ${seed.label}` : ""}
</p>
</div>
<button
type="button"
onClick={() => setHistoryOpen(true)}
aria-label={t("chat.history")}
title={t("chat.history")}
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"
>
<HistoryIcon className="size-5" />
</button>
<ThemeToggle className="hidden lg:grid" />
<button
type="button"
@ -418,17 +599,25 @@ export function ChatScreen() {
</div>
) : null}
{messages.map((message) => (
<MessageRow
<motion.div
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}
/>
initial={reduceMotion ? false : { opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={reduceMotion ? { duration: 0 } : springGentle}
>
<MessageRow
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}
onToFlashcards={() => toFlashcards(message)}
onSaveNotes={noteTargetDeck ? () => saveToNotes(message) : undefined}
/>
</motion.div>
))}
</>
)}
@ -495,13 +684,17 @@ export function ChatScreen() {
onChange={(next) => {
setModel(next);
storeChatModel(next);
if (messages.length > 0) persist(messages, { model: next });
}}
/>
<ContextPicker
open={scopeOpen}
onClose={() => setScopeOpen(false)}
scope={scope}
onChange={setScope}
onChange={(next) => {
setScope(next);
if (messages.length > 0) persist(messages, { scope: next });
}}
decks={decks}
folders={folders}
/>
@ -515,6 +708,20 @@ export function ChatScreen() {
</Link>
</p>
) : null}
</div>
<HistorySheet
open={historyOpen}
onClose={() => setHistoryOpen(false)}
conversations={conversations}
currentId={conversationId}
query={query}
onQueryChange={setQuery}
onOpen={openConversation}
onRename={renameConversation}
onDelete={handleDeleteConversation}
onNew={newChat}
/>
</div>
);
}

View file

@ -122,7 +122,7 @@ export function Composer({
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"
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-[height,border-color] duration-150 ease-out placeholder:text-[var(--text-subtle)] focus:border-accent-400/60 motion-reduce:transition-none"
/>
{busy ? (

View file

@ -1,9 +1,11 @@
"use client";
import { useEffect } from "react";
import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
import { CheckCircleIcon, FolderIcon, LayersIcon } from "@/components/icons";
import { useI18n } from "@/lib/i18n";
import { springSheet } from "@/lib/motion";
import { useIsPhone } from "@/lib/use-phone";
import { deckHasNotes } from "@/lib/chat-context";
import type { ChatScope } from "@/lib/chat";
@ -31,6 +33,7 @@ export function ContextPicker({
}) {
const { t, plural } = useI18n();
const isPhone = useIsPhone();
const reduceMotion = useReducedMotion();
useEffect(() => {
if (!open) return;
@ -41,8 +44,6 @@ export function ContextPicker({
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) => {
@ -171,29 +172,56 @@ export function ContextPicker({
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>
<AnimatePresence>
{open ? (
<div className="fixed inset-0 z-50" role="dialog" aria-modal="true" aria-label={t("chat.scope.title")}>
<motion.button
type="button"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={reduceMotion ? { duration: 0 } : undefined}
className="absolute inset-0 bg-black/45"
aria-label={t("common.close")}
onClick={onClose}
/>
<motion.div
initial={reduceMotion ? { opacity: 0 } : { y: "100%" }}
animate={reduceMotion ? { opacity: 1 } : { y: 0 }}
exit={reduceMotion ? { opacity: 0 } : { y: "100%" }}
transition={reduceMotion ? { duration: 0 } : springSheet}
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>
</motion.div>
</div>
) : null}
</AnimatePresence>
);
}
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>
<AnimatePresence>
{open ? (
<motion.div
initial={reduceMotion ? { opacity: 0 } : { opacity: 0, y: 6, scale: 0.98 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={reduceMotion ? { opacity: 0 } : { opacity: 0, y: 6, scale: 0.98 }}
transition={reduceMotion ? { duration: 0 } : { duration: 0.16, ease: "easeOut" }}
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>
</motion.div>
) : null}
</AnimatePresence>
);
}

View file

@ -0,0 +1,205 @@
"use client";
import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
import { useEffect, useState } from "react";
import { CheckCircleIcon, PencilIcon, PlusIcon, XIcon } from "@/components/icons";
import { Button } from "@/components/ui";
import { useI18n } from "@/lib/i18n";
import { springSheet } from "@/lib/motion";
import { conversationMatches } from "@/lib/chat";
import type { ChatConversation } from "@/lib/schemas";
import { cn, formatDate } from "@/lib/utils";
type HistoryProps = {
conversations: ChatConversation[];
currentId: string | null;
query: string;
onQueryChange: (value: string) => void;
onOpen: (id: string) => void;
onRename: (id: string, title: string) => void;
onDelete: (conversation: ChatConversation) => void;
onNew: () => void;
};
/** The conversation list: a column on desktop, a sheet on phones. */
export function ChatHistoryList({
conversations,
currentId,
query,
onQueryChange,
onOpen,
onRename,
onDelete,
onNew,
}: HistoryProps) {
const { t } = useI18n();
const [renaming, setRenaming] = useState<string | null>(null);
const [draft, setDraft] = useState("");
const visible = conversations.filter((conversation) =>
conversationMatches(conversation.title, conversation.messages, query),
);
const startRename = (conversation: ChatConversation) => {
setRenaming(conversation.id);
setDraft(conversation.title);
};
const commit = () => {
if (renaming) onRename(renaming, draft);
setRenaming(null);
};
return (
<div className="flex min-h-0 flex-1 flex-col gap-2">
<div className="flex items-center gap-2 px-1">
<input
type="search"
value={query}
onChange={(event) => onQueryChange(event.target.value)}
placeholder={t("chat.searchChats")}
aria-label={t("chat.searchChats")}
className="min-w-0 flex-1 rounded-xl border border-[var(--hairline)] bg-[var(--surface-strong)] px-2.5 py-2 text-[16px] text-[var(--text)] outline-none transition placeholder:text-[var(--text-subtle)] focus:border-accent-400/60 sm:text-[13px]"
/>
<Button size="sm" variant="secondary" className="touch-target shrink-0" onClick={onNew} aria-label={t("chat.new")}>
<PlusIcon className="size-4" />
<span className="hidden sm:inline">{t("chat.new")}</span>
</Button>
</div>
{visible.length === 0 ? (
<p className="px-2 py-8 text-center text-[12.5px] text-[var(--text-subtle)]">
{query ? t("chat.noResults") : t("chat.noHistory")}
</p>
) : (
<ul className="thin-scroll min-h-0 flex-1 space-y-1 overflow-y-auto overscroll-contain px-1 pb-2">
{visible.map((conversation) => {
const active = conversation.id === currentId;
return (
<li key={conversation.id}>
{renaming === conversation.id ? (
<form
onSubmit={(event) => {
event.preventDefault();
commit();
}}
className="flex items-center gap-1 px-1 py-1"
>
<input
autoFocus
value={draft}
onChange={(event) => setDraft(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Escape") setRenaming(null);
}}
aria-label={t("common.rename")}
className="min-w-0 flex-1 rounded-lg border border-[var(--hairline)] bg-[var(--surface-strong)] px-2 py-1.5 text-[16px] text-[var(--text)] outline-none sm:text-[13px]"
/>
<button
type="submit"
aria-label={t("common.save")}
className="grid size-9 shrink-0 place-items-center rounded-lg text-emerald-600 transition hover:bg-[var(--hover)] dark:text-emerald-300"
>
<CheckCircleIcon className="size-4" />
</button>
<button
type="button"
onClick={() => setRenaming(null)}
aria-label={t("common.cancel")}
className="grid size-9 shrink-0 place-items-center rounded-lg text-[var(--text-subtle)] transition hover:bg-[var(--hover)]"
>
<XIcon className="size-4" />
</button>
</form>
) : (
<div className="flex items-center gap-1">
<button
type="button"
onClick={() => onOpen(conversation.id)}
aria-current={active ? "true" : undefined}
className={cn(
"min-w-0 flex-1 rounded-xl px-2.5 py-2 text-left transition",
active ? "bg-[var(--nav-active-bg)]" : "hover:bg-[var(--hover)]",
)}
>
<span className="block truncate text-[13px] font-medium text-[var(--text)]">
{conversation.title || t("chat.untitled")}
</span>
<span className="block text-[11px] text-[var(--text-subtle)]">
{formatDate(conversation.updatedAt)}
</span>
</button>
<button
type="button"
onClick={() => startRename(conversation)}
aria-label={t("common.rename")}
title={t("common.rename")}
className="grid size-9 shrink-0 place-items-center rounded-lg text-[var(--text-subtle)] transition hover:bg-[var(--hover)] hover:text-[var(--text)]"
>
<PencilIcon className="size-3.5" />
</button>
<button
type="button"
onClick={() => onDelete(conversation)}
aria-label={t("chat.deleteChat")}
title={t("chat.deleteChat")}
className="grid size-9 shrink-0 place-items-center rounded-lg text-[var(--text-subtle)] transition hover:bg-[var(--hover)] hover:text-rose-500"
>
<XIcon className="size-3.5" />
</button>
</div>
)}
</li>
);
})}
</ul>
)}
</div>
);
}
/** Phone sheet wrapper around the list (drag/backdrop close, spring). */
export function HistorySheet({ open, onClose, ...props }: HistoryProps & { open: boolean; onClose: () => void }) {
const { t } = useI18n();
const reduceMotion = useReducedMotion();
useEffect(() => {
if (!open) return;
const onKey = (event: KeyboardEvent) => {
if (event.key === "Escape") onClose();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [open, onClose]);
return (
<AnimatePresence>
{open ? (
<div className="fixed inset-0 z-50" role="dialog" aria-modal="true" aria-label={t("chat.history")}>
<motion.button
type="button"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={reduceMotion ? { duration: 0 } : undefined}
className="absolute inset-0 bg-black/45"
aria-label={t("common.close")}
onClick={onClose}
/>
<motion.div
initial={reduceMotion ? { opacity: 0 } : { y: "100%" }}
animate={reduceMotion ? { opacity: 1 } : { y: 0 }}
exit={reduceMotion ? { opacity: 0 } : { y: "100%" }}
transition={reduceMotion ? { duration: 0 } : springSheet}
className="glass-panel absolute inset-x-0 bottom-0 flex max-h-[80dvh] flex-col rounded-b-none px-3 pb-safe pt-2"
>
<div className="sheet-grabber mx-auto" aria-hidden />
<h2 className="px-1 pb-2 pt-2 text-[15px] font-semibold text-[var(--text)]">{t("chat.history")}</h2>
<ChatHistoryList {...props} />
</motion.div>
</div>
) : null}
</AnimatePresence>
);
}

View file

@ -7,7 +7,7 @@ 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 { PencilIcon, PlusIcon, RefreshIcon, SparklesIcon } from "@/components/icons";
import { useI18n } from "@/lib/i18n";
import type { ChatMessage } from "@/lib/chat";
@ -24,6 +24,8 @@ export function MessageRow({
onRegenerate,
onEdit,
onRetry,
onToFlashcards,
onSaveNotes,
}: {
message: ChatMessage;
streaming?: boolean;
@ -33,6 +35,8 @@ export function MessageRow({
onRegenerate?: () => void;
onEdit?: () => void;
onRetry?: () => void;
onToFlashcards?: () => void;
onSaveNotes?: () => void;
}) {
const { t } = useI18n();
@ -108,6 +112,26 @@ export function MessageRow({
{t("chat.regenerate")}
</button>
) : null}
{onToFlashcards ? (
<button
type="button"
onClick={onToFlashcards}
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)]"
>
<SparklesIcon className="size-3.5" />
{t("chat.toFlashcards")}
</button>
) : null}
{onSaveNotes ? (
<button
type="button"
onClick={onSaveNotes}
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)]"
>
<PlusIcon className="size-3.5" />
{t("chat.saveToNotes")}
</button>
) : null}
{message.model ? <span className="text-[11px] text-[var(--text-subtle)]">{message.model}</span> : null}
</div>
) : null}

View file

@ -1,9 +1,11 @@
"use client";
import { useEffect } from "react";
import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
import { CheckCircleIcon } from "@/components/icons";
import { useI18n } from "@/lib/i18n";
import { springSheet } from "@/lib/motion";
import { useIsPhone } from "@/lib/use-phone";
import type { ChatModelChoice } from "@/lib/chat";
import { cn } from "@/lib/utils";
@ -28,6 +30,7 @@ export function ModelPicker({
}) {
const { t } = useI18n();
const isPhone = useIsPhone();
const reduceMotion = useReducedMotion();
useEffect(() => {
if (!open) return;
@ -38,8 +41,6 @@ export function ModelPicker({
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");
@ -87,29 +88,56 @@ export function ModelPicker({
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>
<AnimatePresence>
{open ? (
<div className="fixed inset-0 z-50" role="dialog" aria-modal="true" aria-label={t("chat.modelTitle")}>
<motion.button
type="button"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={reduceMotion ? { duration: 0 } : undefined}
className="absolute inset-0 bg-black/45"
aria-label={t("common.close")}
onClick={onClose}
/>
<motion.div
initial={reduceMotion ? { opacity: 0 } : { y: "100%" }}
animate={reduceMotion ? { opacity: 1 } : { y: 0 }}
exit={reduceMotion ? { opacity: 0 } : { y: "100%" }}
transition={reduceMotion ? { duration: 0 } : springSheet}
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>
</motion.div>
</div>
) : null}
</AnimatePresence>
);
}
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>
<AnimatePresence>
{open ? (
<motion.div
initial={reduceMotion ? { opacity: 0 } : { opacity: 0, y: 6, scale: 0.98 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={reduceMotion ? { opacity: 0 } : { opacity: 0, y: 6, scale: 0.98 }}
transition={reduceMotion ? { duration: 0 } : { duration: 0.16, ease: "easeOut" }}
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>
</motion.div>
) : null}
</AnimatePresence>
);
}

View file

@ -12,11 +12,12 @@
import { z } from "zod";
import { applyTheme } from "@/components/ThemeToggle";
import { chatHistoryStore, ensureChatsHydrated, replaceChats } from "./chat-store";
import { replaceFolders, folderStore } from "./folders";
import { homeworkStore, replaceHomework } from "./homework";
import { ensureLearningHydrated, learningStore, replaceLearning } from "./history";
import { setLocale, t } from "./i18n";
import { folderSchema, deckSchema, homeworkRecordSchema, answerRecordSchema, srsRecordSchema } from "./schemas";
import { folderSchema, deckSchema, homeworkRecordSchema, answerRecordSchema, srsRecordSchema, chatConversationSchema } from "./schemas";
import { saveSettings, settingsStore } from "./settings";
import { deckStore, replaceDecks } from "./storage";
@ -36,6 +37,8 @@ export const backupDataSchema = z.object({
/** Answer log and SRS schedule (IndexedDB). Optional for old backups. */
history: z.array(answerRecordSchema).max(20_000).optional(),
srs: z.array(srsRecordSchema).max(50_000).optional(),
/** Chat conversations (IndexedDB). Optional for old backups. */
chats: z.array(chatConversationSchema).max(100).optional(),
});
export const backupSchema = z.object({
@ -54,6 +57,7 @@ export type BackupFile = z.infer<typeof backupSchema>;
export async function buildBackup(): Promise<BackupFile> {
await ensureLearningHydrated();
await ensureChatsHydrated();
return {
app: "freezmo",
kind: "backup",
@ -65,6 +69,7 @@ export async function buildBackup(): Promise<BackupFile> {
homework: homeworkStore.getSnapshot(),
history: learningStore.getAnswersSnapshot(),
srs: learningStore.getSrsSnapshot(),
chats: chatHistoryStore.getSnapshot(),
settings: {
haptics: settingsStore.getSnapshot().haptics,
dailyGoal: settingsStore.getSnapshot().dailyGoal,
@ -138,6 +143,7 @@ export type ImportPreview = {
decks: ImportCounts;
folders: ImportCounts;
homework: ImportCounts;
chats: ImportCounts;
};
export function previewImport(backup: BackupFile): ImportPreview {
@ -145,6 +151,7 @@ export function previewImport(backup: BackupFile): ImportPreview {
decks: countMerge(deckStore.getSnapshot(), backup.data.decks),
folders: countMerge(folderStore.getSnapshot(), backup.data.folders),
homework: countMerge(homeworkStore.getSnapshot(), backup.data.homework),
chats: countMerge(chatHistoryStore.getSnapshot(), backup.data.chats ?? []),
};
}
@ -184,6 +191,13 @@ export async function applyImport(
await replaceLearning(answers, [...srsByKey.values()]);
}
// Chat conversations: merge by id (newest wins) or replace wholesale.
await ensureChatsHydrated();
const incomingChats = data.chats ?? [];
if (incomingChats.length > 0) {
replaceChats(mode === "replace" ? incomingChats : mergeById(chatHistoryStore.getSnapshot(), incomingChats));
}
const settings = data.settings;
if (settings) {
if (typeof settings.haptics === "boolean") saveSettings({ haptics: settings.haptics });

195
lib/chat-store.ts Normal file
View file

@ -0,0 +1,195 @@
"use client";
/**
* Chat conversations, kept in IndexedDB so history survives reloads and the
* tab can be closed mid-conversation. The in-memory mirror makes reads
* synchronous for React (`useSyncExternalStore`); writes update the mirror
* immediately and persist asynchronously.
*
* Versioned DB `freezmo-chat` v1 with one store:
* chats keyPath id (index: updatedAt)
*
* Every stored record is validated with the shared zod schema, and the store
* is capped (oldest conversations first) so a long-lived browser can't grow
* without bound. Chats are part of the export/backup and the encrypted sync
* payload — never the API key.
*/
import { chatConversationSchema, type ChatConversation, type ChatMessage } from "./schemas";
import { markSyncDirty } from "./sync-state";
const DB_NAME = "freezmo-chat";
const DB_VERSION = 1;
const CHATS = "chats";
/** Caps: a personal app, but still bounded. */
export const MAX_CONVERSATIONS = 100;
export const MAX_MESSAGES_PER_CHAT = 300;
const EMPTY: ChatConversation[] = [];
type Listener = () => void;
let conversations: ChatConversation[] = [];
let cached: ChatConversation[] = EMPTY;
const listeners = new Set<Listener>();
let hydrated = false;
let hydration: Promise<void> | null = null;
function notify(): void {
cached = [...conversations].sort((a, b) => b.updatedAt - a.updatedAt);
listeners.forEach((listener) => listener());
}
function openDb(): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, DB_VERSION);
request.onupgradeneeded = () => {
const db = request.result;
if (!db.objectStoreNames.contains(CHATS)) {
const store = db.createObjectStore(CHATS, { keyPath: "id" });
store.createIndex("updatedAt", "updatedAt");
}
};
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
async function withDb<T>(mode: IDBTransactionMode, run: (db: IDBDatabase) => T): Promise<T> {
const db = await openDb();
try {
return run(db);
} finally {
window.setTimeout(() => db.close(), 1000);
}
}
function requestToPromise<T>(request: IDBRequest<T>): Promise<T> {
return new Promise((resolve, reject) => {
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
function persist(conversation: ChatConversation): void {
void withDb("readwrite", (db) => {
const transaction = db.transaction(CHATS, "readwrite");
transaction.objectStore(CHATS).put(conversation);
}).catch((error) => console.warn("[freezmo] could not save the chat", error));
}
function removeFromDb(id: string): void {
void withDb("readwrite", (db) => {
const transaction = db.transaction(CHATS, "readwrite");
transaction.objectStore(CHATS).delete(id);
}).catch((error) => console.warn("[freezmo] could not delete the chat", error));
}
/** Loads IndexedDB into the mirror exactly once. Safe to call repeatedly. */
export function ensureChatsHydrated(): Promise<void> {
if (hydrated) return Promise.resolve();
if (hydration) return hydration;
hydration = (async () => {
if (typeof indexedDB === "undefined") {
hydrated = true;
return;
}
try {
await withDb("readonly", async (db) => {
const transaction = db.transaction(CHATS, "readonly");
const loaded = await requestToPromise(transaction.objectStore(CHATS).getAll() as IDBRequest<unknown[]>);
const known = new Set(conversations.map((entry) => entry.id));
const valid: ChatConversation[] = [];
for (const raw of loaded) {
const parsed = chatConversationSchema.safeParse(raw);
if (!parsed.success) continue;
if (known.has(parsed.data.id)) continue;
valid.push(parsed.data);
}
conversations = [...conversations, ...valid]
.sort((a, b) => b.updatedAt - a.updatedAt)
.slice(0, MAX_CONVERSATIONS);
});
} catch (error) {
console.warn("[freezmo] could not read the chat history", error);
} finally {
hydrated = true;
notify();
}
})();
return hydration;
}
export const chatHistoryStore = {
subscribe(listener: Listener): () => void {
listeners.add(listener);
void ensureChatsHydrated();
return () => listeners.delete(listener);
},
getSnapshot(): ChatConversation[] {
return cached;
},
getServerSnapshot(): ChatConversation[] {
return EMPTY;
},
};
export function getConversation(id: string): ChatConversation | undefined {
return conversations.find((entry) => entry.id === id);
}
export function conversationMessages(conversation: ChatConversation): ChatMessage[] {
return conversation.messages.slice(-MAX_MESSAGES_PER_CHAT);
}
/** Creates or replaces one conversation and persists it. */
export function saveConversation(conversation: ChatConversation): void {
const parsed = chatConversationSchema.safeParse({
...conversation,
messages: conversation.messages.slice(-MAX_MESSAGES_PER_CHAT),
});
if (!parsed.success) {
console.warn("[freezmo] skipping invalid chat", parsed.error.issues[0]);
return;
}
const next = parsed.data;
conversations = [next, ...conversations.filter((entry) => entry.id !== next.id)]
.sort((a, b) => b.updatedAt - a.updatedAt)
.slice(0, MAX_CONVERSATIONS);
notify();
persist(next);
markSyncDirty();
}
export function renameConversation(id: string, title: string): void {
const trimmed = title.trim().slice(0, 200);
if (!trimmed) return;
const current = getConversation(id);
if (!current) return;
saveConversation({ ...current, title: trimmed, updatedAt: Date.now() });
}
export function deleteConversation(id: string): void {
conversations = conversations.filter((entry) => entry.id !== id);
notify();
removeFromDb(id);
markSyncDirty();
}
/** Used by backup import and sync: wholesale replacement (no dirty marking —
callers that need it wrap with withoutDirtyTracking). */
export function replaceChats(next: ChatConversation[]): void {
const valid: ChatConversation[] = [];
for (const raw of next) {
const parsed = chatConversationSchema.safeParse(raw);
if (parsed.success) valid.push(parsed.data);
}
conversations = valid.sort((a, b) => b.updatedAt - a.updatedAt).slice(0, MAX_CONVERSATIONS);
notify();
void withDb("readwrite", (db) => {
const transaction = db.transaction(CHATS, "readwrite");
transaction.objectStore(CHATS).clear();
for (const conversation of conversations) transaction.objectStore(CHATS).put(conversation);
}).catch((error) => console.warn("[freezmo] could not replace the chat history", error));
}

View file

@ -1,53 +1,17 @@
"use client";
/**
* Chat types shared by the chat UI, the context builder and the streaming
* client. The API key rule applies here too: nothing in a chat request may
* carry the key anywhere except the `x-ollama-key` header of our own route.
* Chat helpers and types shared by the chat UI. The record shapes themselves
* live in `lib/schemas.ts` (zod, shared with backup/sync); this module
* re-exports them so UI code has one import.
*/
export type ChatRole = "user" | "assistant";
import type { ChatAttachment, ChatMessage, ChatScope, ChatSource } from "./schemas";
/** A context chunk that actually travelled with a request. */
export type ChatSource = {
deckId: string;
deckTitle: string;
section: string;
};
export type ChatAttachment = {
name: string;
kind: "photo" | "document" | "text";
chars: number;
};
export type { ChatAttachment, ChatMessage, ChatScope, ChatSource };
export type ChatError = { message: string; kind?: string };
export type ChatMessage = {
id: string;
role: ChatRole;
content: string;
/** Reasoning streamed separately; hidden behind the Thinking row. */
thinking?: string;
/** Model that produced an assistant turn. */
model?: string;
/** Context chunks sent with this turn (assistant turns display them). */
sources?: ChatSource[];
attachments?: ChatAttachment[];
at: number;
/** Set when an assistant turn failed; content may hold a partial answer. */
error?: ChatError;
};
/**
* What the chat is allowed to ground on. `all` = every deck with notes,
* `none` = general chat, `decks` = a chosen set.
*/
export type ChatScope =
| { kind: "all" }
| { kind: "none" }
| { kind: "decks"; deckIds: string[] };
export type ChatModelChoice = {
name: string;
badge: "text" | "vision" | "both";
@ -81,3 +45,28 @@ export function chatMessageId(prefix: string): string {
: Math.random().toString(36).slice(2, 10);
return `${prefix}_${Date.now().toString(36)}_${rand}`;
}
/** Auto title from the first user turn (markdown stripped, one line). */
export function titleFromMessage(content: string): string {
const line = content
.replace(/[#*`>_]/g, "")
.replace(/\s+/g, " ")
.trim();
if (!line) return "";
return line.length > 60 ? `${line.slice(0, 57)}…` : line;
}
/** Case/diacritic-insensitive search over titles and message text. */
export function conversationMatches(title: string, messages: ChatMessage[], query: string): boolean {
const needle = fold(query);
if (!needle) return true;
if (fold(title).includes(needle)) return true;
return messages.some((message) => fold(message.content).includes(needle));
}
function fold(value: string): string {
return value
.toLowerCase()
.normalize("NFKD")
.replace(/[\u0300-\u036f]/g, "");
}

View file

@ -10,6 +10,8 @@ export type DeckSeed = {
text: string;
difficulty?: "easy" | "medium" | "hard";
count?: number;
/** Wizard step to land on (chat's "turn into flashcards" opens options). */
step?: 1 | 2 | 3;
};
const KEY = "freezmo.deck.seed.v1";

View file

@ -160,7 +160,7 @@ const en: Record<string, string> = {
"settings.exportAll": "Export all data",
"settings.exportNote": "Decks, homework, progress and settings — never your API key.",
"settings.importBackup": "Import backup",
"settings.importPreview": "This backup has {decks} decks, {homework} homework results and {folders} folders.",
"settings.importPreview": "This backup has {decks} decks, {homework} homework results, {folders} folders and {chats} chats.",
"settings.importMergeNote": "Merge keeps what's here and adds the backup; matching IDs are replaced.",
"settings.importReplaceNote": "Replace wipes this browser's data and uses the backup instead.",
"settings.importMerge": "Merge",
@ -975,6 +975,18 @@ const en: Record<string, string> = {
"chat.seedSteps": "Steps",
"chat.notesDeleted": "Notes text deleted",
"chat.deleteNotes": "Delete notes text",
"chat.history": "Chats",
"chat.searchChats": "Search chats",
"chat.noResults": "No chats match that search.",
"chat.noHistory": "No saved chats yet — ask something and it will appear here.",
"chat.untitled": "Untitled chat",
"chat.deleteChat": "Delete chat",
"chat.deletedChat": "Chat deleted",
"chat.toFlashcards": "Turn into flashcards",
"chat.saveToNotes": "Save to notes",
"chat.savedToNotes": "Saved to {deck}'s notes",
"chat.savedToNotesTrimmed": "Saved to {deck}'s notes (trimmed to fit)",
"chat.savedFromChat": "Saved from chat",
"settings.chatNotes": "Keep notes text for chat",
"settings.chatNotesNote": "Chat answers from the notes you added (stored with each deck). Turn off to use only the generated study content.",
"server.chatModelNotAllowed": "That chat model is not on the server's allowlist.",
@ -1080,7 +1092,7 @@ const cs: Record<string, string> = {
"settings.exportAll": "Exportovat všechna data",
"settings.exportNote": "Sady, úkoly, pokrok a nastavení — nikdy tvůj API klíč.",
"settings.importBackup": "Importovat zálohu",
"settings.importPreview": "Záloha obsahuje {decks} sad, {homework} výsledků úkolů a {folders} složek.",
"settings.importPreview": "Záloha obsahuje {decks} sad, {homework} výsledků úkolů, {folders} složek a {chats} chatů.",
"settings.importMergeNote": "Sloučení ponechá stávající data a přidá zálohu; stejná ID se nahradí.",
"settings.importReplaceNote": "Nahrazení smaže data v tomto prohlížeči a použije zálohu.",
"settings.importMerge": "Sloučit",
@ -1895,6 +1907,18 @@ const cs: Record<string, string> = {
"chat.seedSteps": "Postup",
"chat.notesDeleted": "Text poznámek smazán",
"chat.deleteNotes": "Smazat text poznámek",
"chat.history": "Chaty",
"chat.searchChats": "Hledat v chatech",
"chat.noResults": "Tomuto hledání nic neodpovídá.",
"chat.noHistory": "Zatím žádné uložené chaty — na něco se zeptej a objeví se tady.",
"chat.untitled": "Chat bez názvu",
"chat.deleteChat": "Smazat chat",
"chat.deletedChat": "Chat smazán",
"chat.toFlashcards": "Převést na kartičky",
"chat.saveToNotes": "Uložit do poznámek",
"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",
"settings.chatNotes": "Uchovávat text poznámek pro chat",
"settings.chatNotesNote": "Chat odpovídá z poznámek, které jsi přidal (jsou uložené u každé sady). Vypnutím bude používat jen vygenerovaný obsah.",
"server.chatModelNotAllowed": "Tento model chatu není v povoleném seznamu serveru.",

View file

@ -339,3 +339,52 @@ export const srsRecordSchema = z.object({
lastCorrect: z.boolean(),
});
export type SrsRecord = z.infer<typeof srsRecordSchema>;
/* ------------------------------------------------------------------ */
/* Chat (history, sources, scopes) */
/* ------------------------------------------------------------------ */
export const chatSourceSchema = z.object({
deckId: z.string().min(1).max(120),
deckTitle: z.string().max(200),
section: z.string().max(200),
});
export type ChatSource = z.infer<typeof chatSourceSchema>;
export const chatAttachmentSchema = z.object({
name: z.string().max(200),
kind: z.enum(["photo", "document", "text"]),
chars: z.number().int().min(0).max(10_000_000),
});
export type ChatAttachment = z.infer<typeof chatAttachmentSchema>;
export const chatMessageSchema = z.object({
id: z.string().min(1).max(80),
role: z.enum(["user", "assistant"]),
content: z.string().max(20_000),
thinking: z.string().max(20_000).optional(),
model: z.string().max(120).optional(),
sources: z.array(chatSourceSchema).max(40).optional(),
attachments: z.array(chatAttachmentSchema).max(10).optional(),
at: z.number(),
error: z.object({ message: z.string().max(2000), kind: z.string().max(40).optional() }).optional(),
});
export type ChatMessage = z.infer<typeof chatMessageSchema>;
export const chatScopeSchema = z.discriminatedUnion("kind", [
z.object({ kind: z.literal("all") }),
z.object({ kind: z.literal("none") }),
z.object({ kind: z.literal("decks"), deckIds: z.array(z.string().max(120)).max(200) }),
]);
export type ChatScope = z.infer<typeof chatScopeSchema>;
export const chatConversationSchema = z.object({
id: z.string().min(1).max(80),
title: z.string().max(200),
messages: z.array(chatMessageSchema).max(300),
scope: chatScopeSchema,
model: z.string().max(120).optional(),
createdAt: z.number(),
updatedAt: z.number(),
});
export type ChatConversation = z.infer<typeof chatConversationSchema>;

View file

@ -13,6 +13,7 @@
import { z } from "zod";
import { backupDataSchema } from "./backup";
import { chatHistoryStore, ensureChatsHydrated, replaceChats } from "./chat-store";
import { ApiError } from "./client-api";
import { replaceFolders } from "./folders";
import { replaceHomework } from "./homework";
@ -162,6 +163,7 @@ async function apiDelete(id: string): Promise<void> {
async function buildPayload(): Promise<SyncPayload> {
await ensureLearningHydrated();
await ensureChatsHydrated();
return {
app: "freezmo",
kind: "sync",
@ -173,6 +175,7 @@ async function buildPayload(): Promise<SyncPayload> {
homework: homeworkStore.getSnapshot(),
history: learningStore.getAnswersSnapshot(),
srs: learningStore.getSrsSnapshot(),
chats: chatHistoryStore.getSnapshot(),
},
};
}
@ -185,6 +188,7 @@ async function applyRemote(key: CryptoKey, remote: { updatedAt: number; envelope
replaceFolders(parsed.data.folders);
replaceDecks(parsed.data.decks);
replaceHomework(parsed.data.homework);
replaceChats(parsed.data.chats ?? []);
});
await replaceLearning(parsed.data.history ?? [], parsed.data.srs ?? []);
setSyncRecord({ lastSyncedAt: remote.updatedAt, dirtyAt: null, conflict: false });

View file

@ -9,7 +9,7 @@
*
* Bumping CACHE_VERSION is how a new deploy invalidates old assets.
*/
const CACHE_VERSION = "freezmo-v32";
const CACHE_VERSION = "freezmo-v33";
const SHELL_CACHE = `${CACHE_VERSION}-shell`;
const ASSET_CACHE = `${CACHE_VERSION}-assets`;

View file

@ -37,7 +37,7 @@ prints a per-suite summary at the end.
| `phase1-test.mjs` | Backup export/import, persistence row, nudge, encrypted sync (enable/join/push/delete + API validation), usage-limit handling (client + a stub Ollama). |
| `phase2-test.mjs` | Answer history + SRS from a real study run, review/mistakes, stats, exam mode, push reminders hidden without VAPID and validated with it. |
| `phase3-test.mjs` | Check my work (API + phone/desktop UI), follow-ups, guide step explain, turn-into-a-study-deck. |
| `chat-test.mjs` | Chat: streaming API (validation, allowlist, context travel, history window, thinking separation, usage-limit mapping), attachment extraction, entry points, streaming/stop, markdown + KaTeX, model/scope pickers, edit/regenerate, offline, reduced motion. |
| `chat-test.mjs` | Chat: streaming API (validation, allowlist, context travel, history window, thinking separation, usage-limit mapping), attachment extraction, entry points, streaming/stop, markdown + KaTeX, model/scope pickers, edit/regenerate, offline, reduced motion, plus history (IDB persistence, reload, rename/search/delete/undo, scope+model memory), assistant actions, the export payload and a two-device sync round-trip. |
## Extra instances

View file

@ -8,6 +8,7 @@
* Runs against a stub Ollama, so no real model is needed.
*/
import http from "node:http";
import { readFileSync } from "node:fs";
import { DECK, launchBrowser, makeChecker, makePage, portInUse, seed, spawnApp, waitForHttp } from "./lib.mjs";
@ -296,7 +297,8 @@ try {
await phone.page.waitForTimeout(400);
check("model: picker lists both models with availability", (await phone.page.getByText("stub-vision").count()) >= 1 && (await phone.page.getByText("available").count()) >= 1);
await phone.page.getByRole("button", { name: /stub-vision/ }).click();
await phone.page.waitForTimeout(400);
await phone.page.getByRole("dialog", { name: "Chat model" }).waitFor({ state: "detached", timeout: 4000 }).catch(() => {});
await phone.page.waitForTimeout(300);
check("model: choice updates the chip", (await phone.page.getByRole("button", { name: "stub-vision" }).count()) === 1);
check(
"model: choice is remembered",
@ -310,7 +312,8 @@ try {
await phone.page.waitForTimeout(400);
check("scope: picker opens with the modes", (await phone.page.getByText("All notes").count()) >= 1 && (await phone.page.getByText("No notes").count()) >= 1);
await phone.page.getByRole("button", { name: /No notes/ }).first().click();
await phone.page.waitForTimeout(400);
await phone.page.getByRole("dialog", { name: "What may chat read?" }).waitFor({ state: "detached", timeout: 4000 }).catch(() => {});
await phone.page.waitForTimeout(300);
check("scope: chip follows the choice", (await phone.page.getByRole("button", { name: "No notes" }).count()) === 1);
/* 9. UI: thinking row ----------------------------------------------- */
@ -389,7 +392,7 @@ try {
/* 14. UI: new chat + old-decks note --------------------------------- */
await phone.page.getByRole("button", { name: "New chat" }).click();
await phone.page.waitForTimeout(500);
await phone.page.waitForTimeout(900);
check("new chat: clears the conversation", (await phone.page.locator(".chat-md").count()) === 0 && (await phone.page.getByText("Ask your notes anything").count()) === 1);
const oldDeck = { ...DECK, id: "old_deck", title: "Imported deck", sourceText: "" };
@ -401,6 +404,200 @@ try {
check("old decks: scope chip scoped to it", (await oldPage.page.getByText("1 deck").count()) >= 1);
await oldPage.context.close();
/* 16. phase 2: history, actions, sync, export ----------------------- */
{
const hist = await makePage(browser);
await seed(hist.page, { decks: [DECK_NOTES] });
await hist.page.goto(`${APP}/chat?deck=seed_deck`, { waitUntil: "networkidle" });
await hist.page.waitForTimeout(1200);
await hist.page.getByRole("textbox").fill("What is photosynthesis?");
await hist.page.getByRole("button", { name: "Send", exact: true }).click();
await hist.page.waitForTimeout(2600);
const readChats = (target) =>
target.evaluate(
() =>
new Promise((resolve) => {
const request = indexedDB.open("freezmo-chat", 1);
request.onupgradeneeded = () => {
const db = request.result;
if (!db.objectStoreNames.contains("chats")) db.createObjectStore("chats", { keyPath: "id" });
};
request.onsuccess = () => {
const db = request.result;
try {
const all = db.transaction("chats", "readonly").objectStore("chats").getAll();
all.onsuccess = () => {
db.close();
resolve(all.result);
};
all.onerror = () => {
db.close();
resolve([]);
};
} catch {
db.close();
resolve([]);
}
};
request.onerror = () => resolve([]);
}),
);
const stored = await readChats(hist.page);
check("history: conversation saved to IndexedDB", stored.length === 1, String(stored.length));
check("history: auto title from the first question", stored[0]?.title === "What is photosynthesis?", stored[0]?.title);
check("history: messages + sources stored", stored[0]?.messages?.length === 2 && (stored[0]?.messages?.[1]?.sources?.length ?? 0) >= 1);
check("history: URL carries the conversation id", hist.page.url().includes("/chat?c="), hist.page.url());
// Assistant actions: save to notes + turn into flashcards.
const beforeNotes = await hist.page.evaluate(() => JSON.parse(localStorage.getItem("freezmo.decks.v1"))[0].sourceText);
await hist.page.getByRole("button", { name: "Save to notes" }).click();
await hist.page.waitForTimeout(700);
const afterNotes = await hist.page.evaluate(() => JSON.parse(localStorage.getItem("freezmo.decks.v1"))[0].sourceText);
check("actions: save to notes appends to the scoped deck", afterNotes.length > beforeNotes.length && afterNotes.includes("Rayleigh"));
check("actions: saved note is labelled", afterNotes.includes("Saved from chat"));
await hist.page.getByRole("button", { name: "Turn into flashcards" }).click();
await hist.page.waitForTimeout(1600);
check(
"actions: flashcards opens the wizard on the options step",
hist.page.url().includes("/create") && (await hist.page.getByText("2 / 3").count()) === 1,
hist.page.url(),
);
await hist.page.getByRole("button", { name: "Back" }).click();
await hist.page.waitForTimeout(500);
const seededText = await hist.page.locator("textarea").first().inputValue().catch(() => "");
check("actions: answer pre-filled as notes", seededText.includes("Rayleigh"));
// Back to the conversation: it survives, sources included.
await hist.page.goto(`${APP}/chat?c=${stored[0].id}`, { waitUntil: "networkidle" });
await hist.page.waitForTimeout(1600);
check("history: conversation survives navigation + reload", (await hist.page.locator(".chat-md").count()) === 1);
check("history: sources chips survive too", (await hist.page.locator('a[href="/deck/seed_deck"]').count()) >= 1);
// Rename + search + delete/undo in the phone sheet.
await hist.page.getByRole("button", { name: "Chats" }).click();
await hist.page.waitForTimeout(600);
const sheet = hist.page.getByRole("dialog", { name: "Chats" });
check("history: phone sheet lists the chat", (await sheet.getByText("What is photosynthesis?").count()) >= 1);
await sheet.getByRole("button", { name: "Rename" }).click();
await sheet.getByRole("textbox", { name: "Rename" }).fill("Photosynthesis chat");
await sheet.getByRole("button", { name: "Save" }).click();
await hist.page.waitForTimeout(700);
check("history: rename updates the list", (await sheet.getByText("Photosynthesis chat").count()) >= 1);
check("history: rename persisted", (await readChats(hist.page))[0]?.title === "Photosynthesis chat");
await sheet.getByRole("searchbox", { name: "Search chats" }).fill("zzz-nothing");
await hist.page.waitForTimeout(400);
check("history: search filters and explains", (await sheet.getByText("No chats match that search.").count()) === 1);
await sheet.getByRole("searchbox", { name: "Search chats" }).fill("photosynthesis");
await hist.page.waitForTimeout(400);
check("history: search finds by title", (await sheet.getByText("Photosynthesis chat").count()) >= 1);
await sheet.getByRole("searchbox", { name: "Search chats" }).fill("");
await sheet.getByRole("button", { name: "Delete chat" }).click();
await hist.page.waitForTimeout(800);
check("history: delete removes the chat", (await sheet.getByText("Photosynthesis chat").count()) === 0 && (await readChats(hist.page)).length === 0);
await hist.page.getByRole("button", { name: "Undo" }).click();
await hist.page.waitForTimeout(800);
check("history: undo restores the chat", (await readChats(hist.page)).length === 1);
await sheet.getByRole("button", { name: "New chat" }).click();
await hist.page.waitForTimeout(700);
check(
"history: new chat clears the conversation",
(await hist.page.locator(".chat-md").count()) === 0 && (await hist.page.getByText("Ask your notes anything").count()) === 1,
);
// Open from the list again; scope is remembered across a reload.
await hist.page.getByRole("button", { name: "Chats" }).click();
await hist.page.waitForTimeout(600);
await sheet.getByText("Photosynthesis chat").click();
await hist.page.waitForTimeout(1400);
check("history: opening from the list loads it", (await hist.page.locator(".chat-md").count()) === 1);
await hist.page.getByRole("button", { name: /1 deck/ }).click();
await hist.page.waitForTimeout(400);
await hist.page.getByRole("button", { name: /No notes/ }).first().click();
await hist.page.waitForTimeout(500);
await hist.page.reload({ waitUntil: "networkidle" });
await hist.page.waitForTimeout(1600);
check("history: scope remembered after reload", (await hist.page.getByRole("button", { name: "No notes" }).count()) === 1);
// Offline: history readable, sending paused.
await hist.page.context().setOffline(true);
await hist.page.waitForTimeout(400);
check(
"history: readable offline with sending paused",
(await hist.page.locator(".chat-md").count()) === 1 && (await hist.page.getByText(/Offline — sending is paused/).count()) === 1,
);
await hist.page.context().setOffline(false);
// Export includes chats and never the key.
await hist.page.goto(APP, { waitUntil: "networkidle" });
await hist.page.waitForTimeout(900);
await hist.page.locator("nav.tabbar").getByRole("button").click();
await hist.page.getByRole("dialog").waitFor({ timeout: 6000 });
await hist.page.waitForTimeout(700);
const downloadPromise = hist.page.waitForEvent("download");
await hist.page.getByRole("button", { name: "Export all data" }).click();
const download = await downloadPromise;
const raw = readFileSync(await download.path(), "utf8");
const backup = JSON.parse(raw);
check("export: chats included, API key excluded", backup.data.chats?.length === 1 && !raw.includes("test-key"), JSON.stringify(backup.data.chats?.length));
// Encrypted sync carries chats to a second device.
await hist.page.getByRole("button", { name: "Turn on sync" }).click();
await hist.page.waitForTimeout(300);
await hist.page.getByPlaceholder("Sync passphrase").fill("correct horse battery");
await hist.page.getByPlaceholder("Repeat the passphrase").fill("correct horse battery");
await hist.page.getByRole("button", { name: "Start syncing" }).click();
await hist.page.waitForTimeout(3000);
const syncId = await hist.page.evaluate(() => JSON.parse(localStorage.getItem("freezmo.sync.v1")).id);
check("sync: enabled with chats in the payload", /^[a-f0-9]{32}$/.test(syncId), syncId);
await hist.page.keyboard.press("Escape");
await hist.page.waitForTimeout(500);
const second = await makePage(browser);
await seed(second.page, { decks: [] });
await second.page.goto(APP, { waitUntil: "networkidle" });
await second.page.waitForTimeout(900);
await second.page.locator("nav.tabbar").getByRole("button").click();
await second.page.getByRole("dialog").waitFor({ timeout: 6000 });
await second.page.waitForTimeout(500);
await second.page.getByRole("button", { name: "Join another sync" }).click();
await second.page.waitForTimeout(300);
await second.page.getByPlaceholder("Sync ID").fill(syncId);
await second.page.getByPlaceholder("Sync passphrase").fill("correct horse battery");
await second.page.getByRole("button", { name: "Join" }).click();
await second.page.waitForTimeout(3500);
const secondChats = await readChats(second.page);
check("sync: joined device received the chat", secondChats.length === 1 && secondChats[0].title === "Photosynthesis chat", JSON.stringify(secondChats.length));
// Clean up the server copy so the shared sync dir stays tidy.
await second.page.getByRole("button", { name: "Delete synced data" }).click();
await second.page.waitForTimeout(300);
await second.page.getByRole("button", { name: "Delete", exact: true }).click();
await second.page.waitForTimeout(1500);
const gone = await second.page.evaluate(async (id) => {
const response = await fetch(`/api/sync?id=${id}`, { headers: { "x-ollama-key": "test-key" } });
return response.status;
}, syncId);
check("sync: server copy removed after the test", gone === 404, String(gone));
await second.context.close();
// Desktop: the history panel sits next to the conversation.
await hist.page.setViewportSize({ width: 1280, height: 900 });
await hist.page.goto(`${APP}/chat?c=${stored[0].id}`, { waitUntil: "networkidle" });
await hist.page.waitForTimeout(1600);
check(
"desktop: history panel lists the chat",
(await hist.page.locator("aside").getByText("Photosynthesis chat").count()) >= 1 && (await hist.page.locator(".chat-md").count()) === 1,
);
await hist.context.close();
}
/* 15. reduced motion still works ------------------------------------ */
const reduced = await makePage(browser, { reducedMotion: "reduce" });
await seed(reduced.page, { decks: [DECK_NOTES] });