freezmo/lib/chat-store.ts
Martin Dvorak 5ca0de920c 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.
2026-10-11 19:03:48 +00:00

195 lines
6.6 KiB
TypeScript

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