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
|
|
@ -37,7 +37,7 @@ prints a per-suite summary at the end.
|
|||
| `phase1-test.mjs` | Backup export/import, persistence row, nudge, encrypted sync (enable/join/push/delete + API validation), usage-limit handling (client + a stub Ollama). |
|
||||
| `phase2-test.mjs` | Answer history + SRS from a real study run, review/mistakes, stats, exam mode, push reminders hidden without VAPID and validated with it. |
|
||||
| `phase3-test.mjs` | Check my work (API + phone/desktop UI), follow-ups, guide step explain, turn-into-a-study-deck. |
|
||||
| `chat-test.mjs` | Chat: streaming API (validation, allowlist, context travel, history window, thinking separation, usage-limit mapping), attachment extraction, entry points, streaming/stop, markdown + KaTeX, model/scope pickers, edit/regenerate, offline, reduced motion. |
|
||||
| `chat-test.mjs` | Chat: streaming API (validation, allowlist, context travel, history window, thinking separation, usage-limit mapping), attachment extraction, entry points, streaming/stop, markdown + KaTeX, model/scope pickers, edit/regenerate, offline, reduced motion, plus history (IDB persistence, reload, rename/search/delete/undo, scope+model memory), assistant actions, the export payload and a two-device sync round-trip. |
|
||||
|
||||
## Extra instances
|
||||
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@
|
|||
* Runs against a stub Ollama, so no real model is needed.
|
||||
*/
|
||||
import http from "node:http";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
import { DECK, launchBrowser, makeChecker, makePage, portInUse, seed, spawnApp, waitForHttp } from "./lib.mjs";
|
||||
|
||||
|
|
@ -296,7 +297,8 @@ try {
|
|||
await phone.page.waitForTimeout(400);
|
||||
check("model: picker lists both models with availability", (await phone.page.getByText("stub-vision").count()) >= 1 && (await phone.page.getByText("available").count()) >= 1);
|
||||
await phone.page.getByRole("button", { name: /stub-vision/ }).click();
|
||||
await phone.page.waitForTimeout(400);
|
||||
await phone.page.getByRole("dialog", { name: "Chat model" }).waitFor({ state: "detached", timeout: 4000 }).catch(() => {});
|
||||
await phone.page.waitForTimeout(300);
|
||||
check("model: choice updates the chip", (await phone.page.getByRole("button", { name: "stub-vision" }).count()) === 1);
|
||||
check(
|
||||
"model: choice is remembered",
|
||||
|
|
@ -310,7 +312,8 @@ try {
|
|||
await phone.page.waitForTimeout(400);
|
||||
check("scope: picker opens with the modes", (await phone.page.getByText("All notes").count()) >= 1 && (await phone.page.getByText("No notes").count()) >= 1);
|
||||
await phone.page.getByRole("button", { name: /No notes/ }).first().click();
|
||||
await phone.page.waitForTimeout(400);
|
||||
await phone.page.getByRole("dialog", { name: "What may chat read?" }).waitFor({ state: "detached", timeout: 4000 }).catch(() => {});
|
||||
await phone.page.waitForTimeout(300);
|
||||
check("scope: chip follows the choice", (await phone.page.getByRole("button", { name: "No notes" }).count()) === 1);
|
||||
|
||||
/* 9. UI: thinking row ----------------------------------------------- */
|
||||
|
|
@ -389,7 +392,7 @@ try {
|
|||
|
||||
/* 14. UI: new chat + old-decks note --------------------------------- */
|
||||
await phone.page.getByRole("button", { name: "New chat" }).click();
|
||||
await phone.page.waitForTimeout(500);
|
||||
await phone.page.waitForTimeout(900);
|
||||
check("new chat: clears the conversation", (await phone.page.locator(".chat-md").count()) === 0 && (await phone.page.getByText("Ask your notes anything").count()) === 1);
|
||||
|
||||
const oldDeck = { ...DECK, id: "old_deck", title: "Imported deck", sourceText: "" };
|
||||
|
|
@ -401,6 +404,200 @@ try {
|
|||
check("old decks: scope chip scoped to it", (await oldPage.page.getByText("1 deck").count()) >= 1);
|
||||
await oldPage.context.close();
|
||||
|
||||
|
||||
/* 16. phase 2: history, actions, sync, export ----------------------- */
|
||||
{
|
||||
const hist = await makePage(browser);
|
||||
await seed(hist.page, { decks: [DECK_NOTES] });
|
||||
await hist.page.goto(`${APP}/chat?deck=seed_deck`, { waitUntil: "networkidle" });
|
||||
await hist.page.waitForTimeout(1200);
|
||||
await hist.page.getByRole("textbox").fill("What is photosynthesis?");
|
||||
await hist.page.getByRole("button", { name: "Send", exact: true }).click();
|
||||
await hist.page.waitForTimeout(2600);
|
||||
|
||||
const readChats = (target) =>
|
||||
target.evaluate(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
const request = indexedDB.open("freezmo-chat", 1);
|
||||
request.onupgradeneeded = () => {
|
||||
const db = request.result;
|
||||
if (!db.objectStoreNames.contains("chats")) db.createObjectStore("chats", { keyPath: "id" });
|
||||
};
|
||||
request.onsuccess = () => {
|
||||
const db = request.result;
|
||||
try {
|
||||
const all = db.transaction("chats", "readonly").objectStore("chats").getAll();
|
||||
all.onsuccess = () => {
|
||||
db.close();
|
||||
resolve(all.result);
|
||||
};
|
||||
all.onerror = () => {
|
||||
db.close();
|
||||
resolve([]);
|
||||
};
|
||||
} catch {
|
||||
db.close();
|
||||
resolve([]);
|
||||
}
|
||||
};
|
||||
request.onerror = () => resolve([]);
|
||||
}),
|
||||
);
|
||||
|
||||
const stored = await readChats(hist.page);
|
||||
check("history: conversation saved to IndexedDB", stored.length === 1, String(stored.length));
|
||||
check("history: auto title from the first question", stored[0]?.title === "What is photosynthesis?", stored[0]?.title);
|
||||
check("history: messages + sources stored", stored[0]?.messages?.length === 2 && (stored[0]?.messages?.[1]?.sources?.length ?? 0) >= 1);
|
||||
check("history: URL carries the conversation id", hist.page.url().includes("/chat?c="), hist.page.url());
|
||||
|
||||
// Assistant actions: save to notes + turn into flashcards.
|
||||
const beforeNotes = await hist.page.evaluate(() => JSON.parse(localStorage.getItem("freezmo.decks.v1"))[0].sourceText);
|
||||
await hist.page.getByRole("button", { name: "Save to notes" }).click();
|
||||
await hist.page.waitForTimeout(700);
|
||||
const afterNotes = await hist.page.evaluate(() => JSON.parse(localStorage.getItem("freezmo.decks.v1"))[0].sourceText);
|
||||
check("actions: save to notes appends to the scoped deck", afterNotes.length > beforeNotes.length && afterNotes.includes("Rayleigh"));
|
||||
check("actions: saved note is labelled", afterNotes.includes("Saved from chat"));
|
||||
|
||||
await hist.page.getByRole("button", { name: "Turn into flashcards" }).click();
|
||||
await hist.page.waitForTimeout(1600);
|
||||
check(
|
||||
"actions: flashcards opens the wizard on the options step",
|
||||
hist.page.url().includes("/create") && (await hist.page.getByText("2 / 3").count()) === 1,
|
||||
hist.page.url(),
|
||||
);
|
||||
await hist.page.getByRole("button", { name: "Back" }).click();
|
||||
await hist.page.waitForTimeout(500);
|
||||
const seededText = await hist.page.locator("textarea").first().inputValue().catch(() => "");
|
||||
check("actions: answer pre-filled as notes", seededText.includes("Rayleigh"));
|
||||
|
||||
// Back to the conversation: it survives, sources included.
|
||||
await hist.page.goto(`${APP}/chat?c=${stored[0].id}`, { waitUntil: "networkidle" });
|
||||
await hist.page.waitForTimeout(1600);
|
||||
check("history: conversation survives navigation + reload", (await hist.page.locator(".chat-md").count()) === 1);
|
||||
check("history: sources chips survive too", (await hist.page.locator('a[href="/deck/seed_deck"]').count()) >= 1);
|
||||
|
||||
// Rename + search + delete/undo in the phone sheet.
|
||||
await hist.page.getByRole("button", { name: "Chats" }).click();
|
||||
await hist.page.waitForTimeout(600);
|
||||
const sheet = hist.page.getByRole("dialog", { name: "Chats" });
|
||||
check("history: phone sheet lists the chat", (await sheet.getByText("What is photosynthesis?").count()) >= 1);
|
||||
await sheet.getByRole("button", { name: "Rename" }).click();
|
||||
await sheet.getByRole("textbox", { name: "Rename" }).fill("Photosynthesis chat");
|
||||
await sheet.getByRole("button", { name: "Save" }).click();
|
||||
await hist.page.waitForTimeout(700);
|
||||
check("history: rename updates the list", (await sheet.getByText("Photosynthesis chat").count()) >= 1);
|
||||
check("history: rename persisted", (await readChats(hist.page))[0]?.title === "Photosynthesis chat");
|
||||
|
||||
await sheet.getByRole("searchbox", { name: "Search chats" }).fill("zzz-nothing");
|
||||
await hist.page.waitForTimeout(400);
|
||||
check("history: search filters and explains", (await sheet.getByText("No chats match that search.").count()) === 1);
|
||||
await sheet.getByRole("searchbox", { name: "Search chats" }).fill("photosynthesis");
|
||||
await hist.page.waitForTimeout(400);
|
||||
check("history: search finds by title", (await sheet.getByText("Photosynthesis chat").count()) >= 1);
|
||||
await sheet.getByRole("searchbox", { name: "Search chats" }).fill("");
|
||||
|
||||
await sheet.getByRole("button", { name: "Delete chat" }).click();
|
||||
await hist.page.waitForTimeout(800);
|
||||
check("history: delete removes the chat", (await sheet.getByText("Photosynthesis chat").count()) === 0 && (await readChats(hist.page)).length === 0);
|
||||
await hist.page.getByRole("button", { name: "Undo" }).click();
|
||||
await hist.page.waitForTimeout(800);
|
||||
check("history: undo restores the chat", (await readChats(hist.page)).length === 1);
|
||||
|
||||
await sheet.getByRole("button", { name: "New chat" }).click();
|
||||
await hist.page.waitForTimeout(700);
|
||||
check(
|
||||
"history: new chat clears the conversation",
|
||||
(await hist.page.locator(".chat-md").count()) === 0 && (await hist.page.getByText("Ask your notes anything").count()) === 1,
|
||||
);
|
||||
|
||||
// Open from the list again; scope is remembered across a reload.
|
||||
await hist.page.getByRole("button", { name: "Chats" }).click();
|
||||
await hist.page.waitForTimeout(600);
|
||||
await sheet.getByText("Photosynthesis chat").click();
|
||||
await hist.page.waitForTimeout(1400);
|
||||
check("history: opening from the list loads it", (await hist.page.locator(".chat-md").count()) === 1);
|
||||
await hist.page.getByRole("button", { name: /1 deck/ }).click();
|
||||
await hist.page.waitForTimeout(400);
|
||||
await hist.page.getByRole("button", { name: /No notes/ }).first().click();
|
||||
await hist.page.waitForTimeout(500);
|
||||
await hist.page.reload({ waitUntil: "networkidle" });
|
||||
await hist.page.waitForTimeout(1600);
|
||||
check("history: scope remembered after reload", (await hist.page.getByRole("button", { name: "No notes" }).count()) === 1);
|
||||
|
||||
// Offline: history readable, sending paused.
|
||||
await hist.page.context().setOffline(true);
|
||||
await hist.page.waitForTimeout(400);
|
||||
check(
|
||||
"history: readable offline with sending paused",
|
||||
(await hist.page.locator(".chat-md").count()) === 1 && (await hist.page.getByText(/Offline — sending is paused/).count()) === 1,
|
||||
);
|
||||
await hist.page.context().setOffline(false);
|
||||
|
||||
// Export includes chats and never the key.
|
||||
await hist.page.goto(APP, { waitUntil: "networkidle" });
|
||||
await hist.page.waitForTimeout(900);
|
||||
await hist.page.locator("nav.tabbar").getByRole("button").click();
|
||||
await hist.page.getByRole("dialog").waitFor({ timeout: 6000 });
|
||||
await hist.page.waitForTimeout(700);
|
||||
const downloadPromise = hist.page.waitForEvent("download");
|
||||
await hist.page.getByRole("button", { name: "Export all data" }).click();
|
||||
const download = await downloadPromise;
|
||||
const raw = readFileSync(await download.path(), "utf8");
|
||||
const backup = JSON.parse(raw);
|
||||
check("export: chats included, API key excluded", backup.data.chats?.length === 1 && !raw.includes("test-key"), JSON.stringify(backup.data.chats?.length));
|
||||
|
||||
// Encrypted sync carries chats to a second device.
|
||||
await hist.page.getByRole("button", { name: "Turn on sync" }).click();
|
||||
await hist.page.waitForTimeout(300);
|
||||
await hist.page.getByPlaceholder("Sync passphrase").fill("correct horse battery");
|
||||
await hist.page.getByPlaceholder("Repeat the passphrase").fill("correct horse battery");
|
||||
await hist.page.getByRole("button", { name: "Start syncing" }).click();
|
||||
await hist.page.waitForTimeout(3000);
|
||||
const syncId = await hist.page.evaluate(() => JSON.parse(localStorage.getItem("freezmo.sync.v1")).id);
|
||||
check("sync: enabled with chats in the payload", /^[a-f0-9]{32}$/.test(syncId), syncId);
|
||||
await hist.page.keyboard.press("Escape");
|
||||
await hist.page.waitForTimeout(500);
|
||||
|
||||
const second = await makePage(browser);
|
||||
await seed(second.page, { decks: [] });
|
||||
await second.page.goto(APP, { waitUntil: "networkidle" });
|
||||
await second.page.waitForTimeout(900);
|
||||
await second.page.locator("nav.tabbar").getByRole("button").click();
|
||||
await second.page.getByRole("dialog").waitFor({ timeout: 6000 });
|
||||
await second.page.waitForTimeout(500);
|
||||
await second.page.getByRole("button", { name: "Join another sync" }).click();
|
||||
await second.page.waitForTimeout(300);
|
||||
await second.page.getByPlaceholder("Sync ID").fill(syncId);
|
||||
await second.page.getByPlaceholder("Sync passphrase").fill("correct horse battery");
|
||||
await second.page.getByRole("button", { name: "Join" }).click();
|
||||
await second.page.waitForTimeout(3500);
|
||||
const secondChats = await readChats(second.page);
|
||||
check("sync: joined device received the chat", secondChats.length === 1 && secondChats[0].title === "Photosynthesis chat", JSON.stringify(secondChats.length));
|
||||
|
||||
// Clean up the server copy so the shared sync dir stays tidy.
|
||||
await second.page.getByRole("button", { name: "Delete synced data" }).click();
|
||||
await second.page.waitForTimeout(300);
|
||||
await second.page.getByRole("button", { name: "Delete", exact: true }).click();
|
||||
await second.page.waitForTimeout(1500);
|
||||
const gone = await second.page.evaluate(async (id) => {
|
||||
const response = await fetch(`/api/sync?id=${id}`, { headers: { "x-ollama-key": "test-key" } });
|
||||
return response.status;
|
||||
}, syncId);
|
||||
check("sync: server copy removed after the test", gone === 404, String(gone));
|
||||
await second.context.close();
|
||||
|
||||
// Desktop: the history panel sits next to the conversation.
|
||||
await hist.page.setViewportSize({ width: 1280, height: 900 });
|
||||
await hist.page.goto(`${APP}/chat?c=${stored[0].id}`, { waitUntil: "networkidle" });
|
||||
await hist.page.waitForTimeout(1600);
|
||||
check(
|
||||
"desktop: history panel lists the chat",
|
||||
(await hist.page.locator("aside").getByText("Photosynthesis chat").count()) >= 1 && (await hist.page.locator(".chat-md").count()) === 1,
|
||||
);
|
||||
await hist.context.close();
|
||||
}
|
||||
|
||||
/* 15. reduced motion still works ------------------------------------ */
|
||||
const reduced = await makePage(browser, { reducedMotion: "reduce" });
|
||||
await seed(reduced.page, { decks: [DECK_NOTES] });
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue