- 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.
195 lines
6.6 KiB
TypeScript
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));
|
|
}
|