Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 32 additions & 1 deletion apps/glow/bot/src/chat.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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;
})();
}
Expand Down
197 changes: 197 additions & 0 deletions apps/glow/bot/test/legacy-chat.test.js
Original file line number Diff line number Diff line change
@@ -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()], []);
});
35 changes: 35 additions & 0 deletions apps/glow/tests/test_glow_bot.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand Down
43 changes: 40 additions & 3 deletions apps/glow/web/bot/bot.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand All @@ -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;
Expand Down Expand Up @@ -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). */
Expand Down
Loading