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
|
|
@ -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
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));
|
||||
}
|
||||
71
lib/chat.ts
71
lib/chat.ts
|
|
@ -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, "");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue