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:
parent
756a6257a2
commit
5ca0de920c
19 changed files with 1107 additions and 113 deletions
195
lib/chat-store.ts
Normal file
195
lib/chat-store.ts
Normal 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));
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue