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