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

@ -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 });