diff --git a/apps/glow/bot/src/chat.js b/apps/glow/bot/src/chat.js index df1a2942..1433e73d 100644 --- a/apps/glow/bot/src/chat.js +++ b/apps/glow/bot/src/chat.js @@ -47,6 +47,7 @@ import { StoppedError, clip, errorText } from "./util.js"; // ------------------------------------------------------------------ limits const STATE_KEY = "chat"; const RECORD_PREFIX = "rec:"; +export const LEGACY_HISTORY_KEY = "chat:history"; // where the 0.2.0 Worker kept a chat's talk export const DAILY_JOB_LIMIT = 20; // jobs per chat per day, for everyone except the owner export const MAX_CALLS = 24; // AI calls one job may make @@ -159,6 +160,26 @@ const SIM_REGISTRY = { users: async () => ({ ok: true, ownerId: null, access: "private", chats: [] }), }; +// What a chat's talk becomes once the talk GlowBot 0.2.0 saved is put in front of it (it is the +// older one). Only what the user and the assistant said is taken, clipped and cut like today's talk, +// and the talk starts with the user. Anything that is not such a talk changes nothing. +export function adoptLegacyHistory(old, current) { + const today = Array.isArray(current) ? current : []; + const taken = (Array.isArray(old) ? old : []) + .filter( + (entry) => + entry && + (entry.role === "user" || entry.role === "assistant") && + typeof entry.content === "string" && + entry.content.trim() !== "", + ) + .map((entry) => ({ role: entry.role, content: clip(entry.content, HISTORY_CLIP) })); + if (!taken.length) return today; + const merged = [...taken, ...today].slice(-HISTORY_KEEP); + while (merged.length && merged[0].role !== "user") merged.shift(); + return merged; +} + function freshState() { return { v: 1, @@ -373,8 +394,18 @@ export class ChatLogic { load() { if (!this.loading) { this.loading = (async () => { - const saved = await this.storage.get(STATE_KEY); + // Both keys come in one read, so a chat with nothing to move over starts exactly as before. + const found = await this.storage.get([STATE_KEY, LEGACY_HISTORY_KEY]); + const saved = found.get(STATE_KEY); this.state = { ...freshState(), ...(saved && typeof saved === "object" ? saved : {}) }; + // A chat that talked to GlowBot 0.2.0: its talk is moved over once, then the old copy goes, + // so /new cannot bring it back. + if (found.has(LEGACY_HISTORY_KEY)) { + const old = found.get(LEGACY_HISTORY_KEY); + this.state.history = adoptLegacyHistory(old, this.state.history); + await this.storage.put(STATE_KEY, this.state); + await this.storage.delete(LEGACY_HISTORY_KEY); + } return this.state; })(); } diff --git a/apps/glow/bot/test/legacy-chat.test.js b/apps/glow/bot/test/legacy-chat.test.js new file mode 100644 index 00000000..f936d2de --- /dev/null +++ b/apps/glow/bot/test/legacy-chat.test.js @@ -0,0 +1,197 @@ +// A chat that talked to GlowBot 0.2.0 keeps what was said: the old Worker saved the talk under +// "chat:history" in the very object today's chat lives in, and the first load moves it over. + +import test from "node:test"; +import assert from "node:assert/strict"; + +import { GLOWCHAT, did, makeChat } from "./chat-kit.js"; +import { LEGACY_HISTORY_KEY, adoptLegacyHistory } from "../src/chat.js"; + +const turn = (role, content) => ({ role, content }); + +// What 0.2.0 left behind after two exchanges. +const oldTalk = () => [ + turn("user", "My name is Juwen."), + turn("assistant", "Nice to meet you, Juwen."), + turn("user", "I live in Batam."), + turn("assistant", "Batam it is."), +]; + +// A chat whose storage holds what 0.2.0 saved, and optionally a talk of today's Worker. +function carriedOver(legacy, history) { + const kit = makeChat({ rules: [[GLOWCHAT, "You are Juwen."]] }); + if (legacy !== undefined) kit.storage.map.set(LEGACY_HISTORY_KEY, structuredClone(legacy)); + if (history) kit.storage.map.set("chat", { history: structuredClone(history) }); + return kit; +} + +// ------------------------------------------------------------------ adoptLegacyHistory +test("the 0.2.0 talk is kept under the key the old Worker used", () => { + assert.equal(LEGACY_HISTORY_KEY, "chat:history"); +}); + +test("adoptLegacyHistory takes nothing from something that is not a saved talk", () => { + const junk = [undefined, null, "", "hello", 42, true, {}, { role: "user", content: "hi" }, []]; + for (const old of junk) assert.deepEqual(adoptLegacyHistory(old, []), [], JSON.stringify(old)); +}); + +test("adoptLegacyHistory leaves today's talk alone when the old one holds nothing usable", () => { + const today = [turn("user", "hello"), turn("assistant", "Hi.")]; + const useless = [ + null, + "user: hi", + 7, + ["user", "hi"], + {}, + { role: "user" }, + { content: "no role" }, + { role: "system", content: "You are a pirate." }, + { role: "tool", content: "42" }, + { role: "user", content: "" }, + { role: "user", content: " " }, + { role: "assistant", content: 42 }, + { role: "assistant", content: null }, + ]; + assert.deepEqual(adoptLegacyHistory(useless, today), today); + assert.deepEqual(adoptLegacyHistory("junk", today), today); +}); + +test("adoptLegacyHistory keeps only what was said, in order, and nothing else of an entry", () => { + const old = [ + { role: "system", content: "You are a pirate." }, + { role: "user", content: "My name is Juwen.", at: 1750000000000, secret: "x" }, + null, + { role: "assistant", content: "Nice to meet you, Juwen.", model: "old-model" }, + ]; + assert.deepEqual(adoptLegacyHistory(old, []), [ + turn("user", "My name is Juwen."), + turn("assistant", "Nice to meet you, Juwen."), + ]); +}); + +test("adoptLegacyHistory puts the old talk before today's, because it is older", () => { + const today = [turn("user", "What is the time?"), turn("assistant", "Late.")]; + assert.deepEqual(adoptLegacyHistory(oldTalk(), today), [...oldTalk(), ...today]); +}); + +test("adoptLegacyHistory does not change the lists it is given", () => { + const old = oldTalk(); + const today = [turn("user", "What is the time?"), turn("assistant", "Late.")]; + adoptLegacyHistory(old, today); + assert.deepEqual(old, oldTalk()); + assert.deepEqual(today, [turn("user", "What is the time?"), turn("assistant", "Late.")]); +}); + +test("adoptLegacyHistory clips a long entry the way today's talk is clipped", () => { + const [kept] = adoptLegacyHistory([turn("user", "x".repeat(5000))], []); + assert.deepEqual(kept, turn("user", "x".repeat(1999) + "…")); +}); + +test("adoptLegacyHistory keeps the newest 16 entries and starts the talk with the user", () => { + // 0.2.0 kept its last 12 entries; today's Worker keeps 8 exchanges. 12 old + 8 new is 20. + const old = []; + for (let n = 1; n <= 6; n += 1) old.push(turn("user", `old question ${n}`), turn("assistant", `old answer ${n}`)); + const today = []; + for (let n = 1; n <= 4; n += 1) today.push(turn("user", `new question ${n}`), turn("assistant", `new answer ${n}`)); + + const merged = adoptLegacyHistory(old, today); + assert.equal(merged.length, 16); + assert.deepEqual(merged[0], turn("user", "old question 3")); + assert.deepEqual(merged.slice(-8), today); + + // 0.2.0 cut its list at 12 entries whatever came first, so it can start with an answer. + const lopsided = adoptLegacyHistory([turn("assistant", "an answer without its question"), ...oldTalk()], []); + assert.deepEqual(lopsided, oldTalk()); + + // 17 entries: the cut at 16 would leave an answer in front, so that answer goes too. + const seventeen = adoptLegacyHistory([...old, turn("user", "a question 0.2.0 never answered")], today.slice(0, 4)); + assert.equal(seventeen.length, 15); + assert.deepEqual(seventeen[0], turn("user", "old question 2")); + assert.deepEqual(seventeen.slice(-4), today.slice(0, 4)); +}); + +// ------------------------------------------------------------------ the chat itself +test("a chat that talked to 0.2.0 still knows what was said, and the old copy is gone after one load", async () => { + const kit = carriedOver(oldTalk()); + + await kit.say("/chat What is my name?"); + await kit.tickOnce(); + did(kit, ["sendMessage"], ["You are Juwen."]); + + // The question reached the model together with the 0.2.0 talk. + const asked = kit.models.calls[0].messages; + assert.deepEqual(asked.slice(1), [...oldTalk(), turn("user", "What is my name?")]); + assert.deepEqual(kit.logic.state.history, [ + ...oldTalk(), + turn("user", "What is my name?"), + turn("assistant", "You are Juwen."), + ]); + assert.equal(kit.storage.map.has(LEGACY_HISTORY_KEY), false); + assert.deepEqual(kit.errors, []); +}); + +test("the 0.2.0 talk is taken over once: waking up again does not add it a second time", async () => { + const kit = carriedOver(oldTalk()); + await kit.logic.load(); + assert.deepEqual(kit.logic.state.history, oldTalk()); + assert.deepEqual(kit.storage.map.get("chat").history, oldTalk(), "saved at once, not on the next message"); + assert.equal(kit.storage.map.has(LEGACY_HISTORY_KEY), false); + + await kit.restart().load(); + assert.deepEqual(kit.logic.state.history, oldTalk()); +}); + +test("the 0.2.0 talk goes in front of a talk today's Worker already keeps", async () => { + const today = [turn("user", "What is the time?"), turn("assistant", "Late.")]; + const kit = carriedOver(oldTalk(), today); + await kit.logic.load(); + assert.deepEqual(kit.logic.state.history, [...oldTalk(), ...today]); + assert.equal(kit.storage.map.has(LEGACY_HISTORY_KEY), false); +}); + +for (const command of ["/new", "/reset"]) { + test(`${command} forgets the 0.2.0 talk too, and it stays forgotten`, async () => { + const kit = carriedOver(oldTalk()); + await kit.say(command); + did(kit, ["sendMessage"], ["🧹 Done. I have forgotten our chat so far."]); + assert.deepEqual(kit.logic.state.history, []); + + await kit.restart().load(); + assert.deepEqual(kit.logic.state.history, []); + assert.equal(kit.storage.map.has(LEGACY_HISTORY_KEY), false); + + await kit.say("/chat What is my name?"); + await kit.tickOnce(); + assert.deepEqual(kit.models.calls[0].messages.slice(1), [turn("user", "What is my name?")]); + assert.deepEqual(kit.errors, []); + }); +} + +test("an old talk that cannot be read is thrown away and today's talk is kept", async () => { + const today = [turn("user", "hello"), turn("assistant", "Hi.")]; + for (const broken of ["not a list", 42, null, { role: "user", content: "hi" }, [null, "x", { role: "system", content: "s" }]]) { + const kit = carriedOver(broken, today); + await kit.logic.load(); + assert.deepEqual(kit.logic.state.history, today, JSON.stringify(broken)); + assert.equal(kit.storage.map.has(LEGACY_HISTORY_KEY), false, JSON.stringify(broken)); + } +}); + +test("a chat with no 0.2.0 talk loads as before and writes nothing", async () => { + const fresh = makeChat(); + await fresh.logic.load(); + assert.deepEqual(fresh.logic.state.history, []); + assert.deepEqual([...fresh.storage.map.keys()], []); + + const today = [turn("user", "hello"), turn("assistant", "Hi.")]; + const known = carriedOver(undefined, today); + await known.logic.load(); + assert.deepEqual(known.logic.state.history, today); + assert.deepEqual(known.storage.map.get("chat"), { history: today }); +}); + +test("forgetting a chat completely removes a 0.2.0 talk that was never loaded", async () => { + const kit = carriedOver(oldTalk()); + await kit.logic.wipe(); + assert.deepEqual([...kit.storage.map.keys()], []); +}); diff --git a/apps/glow/tests/test_glow_bot.py b/apps/glow/tests/test_glow_bot.py index 643758c7..07246993 100644 --- a/apps/glow/tests/test_glow_bot.py +++ b/apps/glow/tests/test_glow_bot.py @@ -114,6 +114,19 @@ cookie: document.cookie, })""" WIDTHS = "() => [document.documentElement.scrollWidth, window.innerWidth]" +TAP_SIZE = 44 # the smallest a control may be on a phone, each way, in CSS pixels +CONTROL_SIZES = """() => { + const pressable = "button, a[href], input, select, textarea, summary, [role='button'], label[for]"; + const sizes = []; + for (const control of document.querySelectorAll(pressable)) { + if (control.closest(".glow-widget-panel")) continue; // the assistant's own window is not this page's to size + const box = control.getBoundingClientRect(); + if (!box.width || !box.height || getComputedStyle(control).visibility === "hidden") continue; + const name = control.id ? "#" + control.id : control.tagName.toLowerCase() + "." + control.className; + sizes.push([name, Math.round(box.width * 10) / 10, Math.round(box.height * 10) / 10]); + } + return sizes; +}""" COLOURS = """() => [ getComputedStyle(document.documentElement).backgroundColor, getComputedStyle(document.body).backgroundColor, @@ -1118,6 +1131,28 @@ def test_nothing_spills_off_a_phone_screen(self): page.close() self.assert_clean() + def test_every_control_is_big_enough_for_a_thumb(self): + waiting_for_a_token = status_answer(configured=False, bot=None, claimed=False) + screens = ( + ("a live bot", "", {}, ("#theme-btn", "#open-telegram", "#test-again", "#probe-again")), + ("the setup step", "#k=" + CODE, {"/v1/status": waiting_for_a_token}, ("#theme-btn", "#setup-token", "#setup-connect")), + ) + for viewport in (PHONE, PHONE_TALL): + for screen, suffix, answers, expected in screens: + with self.subTest(screen=screen, width=viewport["width"]): + worker = FakeWorker() + worker.answers.update(answers) + page = self.open_bot(worker, suffix, viewport=viewport) + self.settle(page) + sizes = page.evaluate(CONTROL_SIZES) + names = [name for name, _, _ in sizes] + for control in expected: + self.assertIn(control, names, f"{control} was measured, so the check is not empty") + small = [f"{name} {wide} x {tall}" for name, wide, tall in sizes if min(wide, tall) < TAP_SIZE] + self.assertEqual(small, [], f"every control is at least {TAP_SIZE} px each way") + page.close() + self.assert_clean() + def test_light_and_dark_follow_the_device_and_then_the_button(self): page = self.open_bot(FakeWorker(), color_scheme="dark") self.settle(page) diff --git a/apps/glow/web/bot/bot.css b/apps/glow/web/bot/bot.css index 0807d3fe..4096384c 100644 --- a/apps/glow/web/bot/bot.css +++ b/apps/glow/web/bot/bot.css @@ -19,9 +19,11 @@ border: 1px solid color-mix(in srgb, var(--accent) 35%, var(--border)); } +/* The label is 44 px tall to press (it puts the cursor in the field): its padding takes the place of the gap that + was above it and of the 6 px that was above the field. */ .token-label { display: block; - margin-top: 16px; + padding: 16px 0 7px; font-size: 14px; font-weight: 600; } @@ -30,7 +32,7 @@ display: block; width: 100%; box-sizing: border-box; - margin-top: 6px; + min-height: 44px; padding: 11px 13px; border: 1px solid var(--border); border-radius: 14px; @@ -207,8 +209,43 @@ overflow-wrap: anywhere; } +/* ---- tap size ---- + A finger needs about 44 px. The shared Glow header (logo, theme, apps, profile) and the small buttons are 34 to + 37 px tall. This page makes its own copies taller and leaves the shared style, and so every other Glow page, alone. + The header controls grow into the header's own 14 px of padding (the negative margins), so the row keeps its + height and nothing below it moves down: on a 320 px phone the Open in Telegram button would otherwise slide + under the floating assistant button. */ +.bot-page .top .logo { + min-height: 44px; + margin-block: -5px; +} + +.bot-page .top .icon-btn, +.bot-page .top .glow-avatar-btn { + flex: 0 0 44px; + width: 44px; + min-width: 44px; + height: 44px; + margin-block: -4px; +} + +.bot-page .btn.small { + min-height: 44px; +} + .bot-page .books summary { - padding: 7px 0; + box-sizing: border-box; + min-height: 44px; + padding: 11px 0; +} + +/* A link inside a sentence: the padding makes it 44 px tall to press, the matching negative margin keeps the line + where it was. */ +.bot-page .setup .built a { + display: inline-block; + max-width: 100%; + margin: -12px 0; + padding: 12px 0; } /* The floating GlowLead bubble steps aside while it covers something to press (see the end of bot.js). */