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
64 changes: 44 additions & 20 deletions app/src/features/account/TokensByBot.tsx
Original file line number Diff line number Diff line change
@@ -1,24 +1,28 @@
// Tokens each bot used over a period (spec 8.7), in the usage dialog: the
// text read plus the replies written. Each bot shows its crew, as two crews can have
// bots with the same name.
// What each bot used over a period (spec 8.7), in the usage dialog: about
// how much of the weekly plan, once Botloft has learned it from how the
// plan rises while the bots work, and the tokens read and written. Each
// bot shows its crew, as two crews can have bots with the same name.

import { useEffect, useState } from "react";
import { useT } from "../../i18n";
import { errorText } from "../../lib/api";
import { tokens, usedTokens } from "../../lib/format";
import { planPercent, tokens, usedTokens } from "../../lib/format";
import type { BotTokens } from "../../lib/protocol.gen";
import { useApi } from "../../store/context";
import { Callout } from "../../ui/Callout";
import { Choices } from "../../ui/Choices";
import { BotAvatar } from "../bots/BotAvatar";

type Period = "today" | "week" | "month" | "all";
type Period = "hour" | "today" | "week" | "month" | "all";

const DAY_MS = 86_400_000;
const HOUR_MS = 3_600_000;
const DAY_MS = 24 * HOUR_MS;

/** When `period` starts, in Unix ms. */
export function periodStart(period: Period, now = Date.now()): number {
switch (period) {
case "hour":
return now - HOUR_MS;
case "today":
return new Date(now).setHours(0, 0, 0, 0);
case "week":
Expand All @@ -30,6 +34,11 @@ export function periodStart(period: Period, now = Date.now()): number {
}
}

/** How much a bot weighs in the list: its plan share, else its tokens. */
function weight(bot: BotTokens): number {
return bot.planShare ?? usedTokens(bot.tokens);
}

export function TokensByBot() {
const u = useT().account.usage.tokens;
const api = useApi();
Expand All @@ -41,19 +50,20 @@ export function TokensByBot() {
let alive = true;
setFailed(null);
api.call("usage.tokens", { since: periodStart(period) }).then(
(list) => alive && setBots(list),
(list) => alive && setBots([...list].sort((a, b) => weight(b) - weight(a))),
(error) => alive && setFailed(errorText(error)),
);
return () => {
alive = false;
};
}, [api, period]);

const periods = (["today", "week", "month", "all"] as const).map((value) => ({
const periods = (["hour", "today", "week", "month", "all"] as const).map((value) => ({
value,
label: u.periods[value],
}));
const most = Math.max(1, ...(bots ?? []).map((bot) => usedTokens(bot.tokens)));
const most = Math.max(Number.MIN_VALUE, ...(bots ?? []).map(weight));
const learned = (bots ?? []).some((bot) => bot.planShare !== null);

return (
<section className="flex flex-col gap-3 border-line border-t pt-4">
Expand All @@ -67,12 +77,15 @@ export function TokensByBot() {
) : bots === null ? null : bots.length === 0 ? (
<p className="text-muted">{u.empty}</p>
) : (
<ul aria-label={u.title} className="flex flex-col gap-3">
{bots.map((bot) => (
<Row key={bot.botId} bot={bot} most={most} />
))}
{bots.length > 1 && <Total bots={bots} />}
</ul>
<>
<ul aria-label={u.title} className="flex flex-col gap-3">
{bots.map((bot) => (
<Row key={bot.botId} bot={bot} most={most} />
))}
{bots.length > 1 && <Total bots={bots} />}
</ul>
<p className="text-muted text-xs leading-relaxed">{learned ? u.estimate : u.learning}</p>
</>
)}
</section>
);
Expand All @@ -81,6 +94,7 @@ export function TokensByBot() {
function Row({ bot, most }: { bot: BotTokens; most: number }) {
const u = useT().account.usage.tokens;
const used = usedTokens(bot.tokens);
const share = bot.planShare;
return (
<li className="flex items-center gap-3">
<BotAvatar color={bot.color} size={24} />
Expand All @@ -90,15 +104,20 @@ function Row({ bot, most }: { bot: BotTokens; most: number }) {
{bot.name}
{bot.archived && <span className="ml-1.5 text-muted text-xs">{u.archived}</span>}
</span>
<span className="shrink-0 tabular-nums">{tokens(used)}</span>
<span className="shrink-0 tabular-nums">
{share === null ? tokens(used) : u.share(planPercent(share))}
</span>
</div>
<div aria-hidden className="h-1 w-full bg-sunken">
<div
className="h-full origin-left animate-grow bg-work"
style={{ width: `${(used / most) * 100}%` }}
style={{ width: `${(weight(bot) / most) * 100}%` }}
/>
</div>
<span className="text-muted text-xs">{u.detail(bot.crew, bot.turns)}</span>
<span className="text-muted text-xs">
{u.detail(bot.crew, bot.turns)}
{share !== null && ` · ${u.tokensUsed(tokens(used))}`}
</span>
</div>
</li>
);
Expand All @@ -108,13 +127,18 @@ function Total({ bots }: { bots: BotTokens[] }) {
const u = useT().account.usage.tokens;
const used = bots.reduce((sum, bot) => sum + usedTokens(bot.tokens), 0);
const turns = bots.reduce((sum, bot) => sum + bot.turns, 0);
const learned = bots.every((bot) => bot.planShare !== null);
const share = bots.reduce((sum, bot) => sum + (bot.planShare ?? 0), 0);
return (
<li className="flex flex-col gap-1 border-line border-t pt-3">
<div className="flex items-baseline justify-between gap-3 font-semibold">
<span>{u.total}</span>
<span className="tabular-nums">{tokens(used)}</span>
<span className="tabular-nums">{learned ? u.share(planPercent(share)) : tokens(used)}</span>
</div>
<span className="text-muted text-xs">{u.detail(null, turns)}</span>
<span className="text-muted text-xs">
{u.detail(null, turns)}
{learned && ` · ${u.tokensUsed(tokens(used))}`}
</span>
</li>
);
}
42 changes: 39 additions & 3 deletions app/src/features/account/tokens.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { App } from "../../App";
import type { Client } from "../../lib/client";
import { FakeBotloft } from "../../lib/fake";
import { FakeHost } from "../../lib/fakeHost";
import { planPercent } from "../../lib/format";
import { periodStart } from "./TokensByBot";

afterEach(cleanup);
Expand All @@ -30,7 +31,7 @@ describe("tokens by bot", () => {
fake.chat.turn(writer.id);

const dialog = await openUsage(fake);
const list = await within(dialog).findByRole("list", { name: "Tokens by bot" });
const list = await within(dialog).findByRole("list", { name: "Use by bot" });
const rows = within(list).getAllByRole("listitem");
// Each turn: 12 new, 1,800 into the cache and 640 written count; 24,000 reread do not.
expect(rows.map((row) => row.textContent)).toEqual([
Expand Down Expand Up @@ -59,7 +60,7 @@ describe("tokens by bot", () => {
fake.chat.turn(scout.id);

const dialog = await openUsage(fake);
const list = await within(dialog).findByRole("list", { name: "Tokens by bot" });
const list = await within(dialog).findByRole("list", { name: "Use by bot" });
expect(
within(list)
.getAllByRole("listitem")
Expand All @@ -75,7 +76,7 @@ describe("tokens by bot", () => {
fake.chat.turn(fake.addBot(news.id, "Scout", "Finds stories").id);

const dialog = await openUsage(fake);
const list = await within(dialog).findByRole("list", { name: "Tokens by bot" });
const list = await within(dialog).findByRole("list", { name: "Use by bot" });
expect(
within(list)
.getAllByRole("listitem")
Expand All @@ -99,3 +100,38 @@ describe("tokens by bot", () => {
expect(periodStart("all", now)).toBe(0);
});
});

describe("each bot's share of the weekly plan", () => {
test("shows once learned, with the tokens beside it, and says it is an estimate", async () => {
const fake = new FakeBotloft();
const ops = fake.addCrew("Ops");
const scout = fake.addBot(ops.id, "Scout", "Finds sources");
fake.chat.turn(scout.id);
fake.chat.turn(scout.id);
// 4,904 tokens at 0.000001 each: 0.49% of the week.
fake.planSharePerToken = 0.000_001;

const dialog = await openUsage(fake);
const list = await within(dialog).findByRole("list", { name: "Use by bot" });
expect(within(list).getByRole("listitem").textContent).toBe(
"Scout≈ 0.5% of the weekOps · Worked 2 times · 4.9k tokens",
);
expect(dialog.textContent).toContain("≈ Estimated:");
fireEvent.click(within(dialog).getByRole("radio", { name: "Last hour" }));
expect(await within(dialog).findByText("≈ 0.5% of the week")).toBeDefined();
});

test("until learned, the tokens and a note that the share comes later", async () => {
const fake = new FakeBotloft();
fake.chat.turn(fake.addBot(fake.addCrew("Ops").id, "Scout", "Finds sources").id);
const dialog = await openUsage(fake);
expect(await within(dialog).findByText(/shows up once the plan has gone up/)).toBeDefined();
});

test("a share reads as a short percentage", () => {
expect(planPercent(0.045)).toBe("4.5%");
expect(planPercent(0.123)).toBe("12%");
expect(planPercent(0.0000001)).toBe("<0.1%");
expect(planPercent(0)).toBe("0%");
});
});
18 changes: 15 additions & 3 deletions app/src/i18n/en/account.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,18 +28,30 @@ export const account = {
limited: "Limit reached. Your bots wait until it resets.",
updated: (relative: string) => `Updated ${relative}`,
tokens: {
title: "Tokens by bot",
title: "Use by bot",
intro:
"Tokens are the pieces of text a bot reads and writes. The count adds the text read and the replies written.",
"How much of your weekly plan each bot used, and the tokens behind it: the pieces of text a bot reads and writes.",
period: "Period",
periods: { today: "Today", week: "7 days", month: "30 days", all: "All time" },
periods: {
hour: "Last hour",
today: "Today",
week: "7 days",
month: "30 days",
all: "All time",
},
empty: "No bot worked in this period.",
failed: "Could not load the tokens",
archived: "archived",
/** `crew` is null on the total of all bots. */
detail: (crew: string | null, times: number) =>
`${crew === null ? "" : `${crew} · `}Worked ${times === 1 ? "once" : `${times} times`}`,
total: "All bots",
share: (percent: string) => `≈ ${percent} of the week`,
tokensUsed: (count: string) => `${count} tokens`,
estimate:
"≈ Estimated: Botloft learns how much of your plan the bots' work takes from how the weekly plan rises while they work. What you use outside Botloft also raises it, so the bots may show a little more than they used.",
learning:
"Each bot's share of your weekly plan shows up once the plan has gone up a few points while the bots work. Until then, the tokens.",
},
},
settings: {
Expand Down
18 changes: 15 additions & 3 deletions app/src/i18n/es/account.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,18 +28,30 @@ export const account: Messages["account"] = {
limited: "Límite alcanzado. Tus bots esperan hasta que se renueve.",
updated: (relative: string) => `Actualizado ${relative}`,
tokens: {
title: "Tokens por bot",
title: "Uso por bot",
intro:
"Los tokens son los trozos de texto que un bot lee y escribe. La cuenta suma el texto leído y las respuestas escritas.",
"Cuánto de tu plan semanal usó cada bot, y los tokens detrás: los trozos de texto que un bot lee y escribe.",
period: "Período",
periods: { today: "Hoy", week: "7 días", month: "30 días", all: "Todo" },
periods: {
hour: "Última hora",
today: "Hoy",
week: "7 días",
month: "30 días",
all: "Todo",
},
empty: "Ningún bot trabajó en este período.",
failed: "No se pudieron cargar los tokens",
archived: "archivado",
/** `crew` is null on the total of all bots. */
detail: (crew: string | null, times: number) =>
`${crew === null ? "" : `${crew} · `}Trabajó ${times === 1 ? "1 vez" : `${times} veces`}`,
total: "Todos los bots",
share: (percent) => `≈ ${percent} de la semana`,
tokensUsed: (count) => `${count} tokens`,
estimate:
"≈ Estimado: Botloft aprende cuánto de tu plan consume el trabajo de los bots según cuánto sube el plan semanal mientras trabajan. Lo que usas fuera de Botloft también lo sube, así que los bots pueden aparecer con un poco más de lo que usaron.",
learning:
"La parte de cada bot en tu plan semanal aparece cuando el plan haya subido unos puntos con los bots trabajando. Hasta entonces, los tokens.",
},
},
settings: {
Expand Down
18 changes: 15 additions & 3 deletions app/src/i18n/pt-BR/account.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,18 +28,30 @@ export const account: Messages["account"] = {
limited: "Limite atingido. Seus bots esperam até ele renovar.",
updated: (relative: string) => `Atualizado ${relative}`,
tokens: {
title: "Tokens por bot",
title: "Uso por bot",
intro:
"Tokens são os pedaços de texto que um bot lê e escreve. A conta soma o texto lido e as respostas escritas.",
"Quanto do seu plano semanal cada bot usou, e os tokens por trás disso: os pedaços de texto que um bot lê e escreve.",
period: "Período",
periods: { today: "Hoje", week: "7 dias", month: "30 dias", all: "Tudo" },
periods: {
hour: "Última hora",
today: "Hoje",
week: "7 dias",
month: "30 dias",
all: "Tudo",
},
empty: "Nenhum bot trabalhou nesse período.",
failed: "Não deu para carregar os tokens",
archived: "arquivado",
/** `crew` is null on the total of all bots. */
detail: (crew: string | null, times: number) =>
`${crew === null ? "" : `${crew} · `}Trabalhou ${times === 1 ? "1 vez" : `${times} vezes`}`,
total: "Todos os bots",
share: (percent) => `≈ ${percent} da semana`,
tokensUsed: (count) => `${count} tokens`,
estimate:
"≈ Estimativa: o Botloft aprende quanto do seu plano o trabalho dos bots consome pelo quanto o plano semanal sobe enquanto eles trabalham. O que você usa fora do Botloft também faz o plano subir, então os bots podem aparecer com um pouco mais do que usaram.",
learning:
"A parte de cada bot no seu plano semanal aparece depois que o plano subir alguns pontos com os bots trabalhando. Até lá, os tokens.",
},
},
settings: {
Expand Down
2 changes: 2 additions & 0 deletions app/src/lib/fake.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,8 @@ export class FakeBotloft implements BotloftApi {
readonly lesson = new FakeLesson(this);
/** Clock for created and updated times. */
now = Date.now();
/** Plan share per token for `usage.tokens`; `null`, not learned yet. */
planSharePerToken: number | null = null;
closed = false;
private state: ConnectionState = { kind: "open", daemonVersion: "0.1.0" };
private readonly listeners = new Set<(event: ServerEvent) => void>();
Expand Down
8 changes: 7 additions & 1 deletion app/src/lib/fakeUsage.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
// The fake daemon's tokens per bot (spec 8.7), summed from the `turn` items
// of the fake chats, as the daemon sums its stored ones.
// of the fake chats, as the daemon sums its stored ones. The share of the
// plan is the tokens times `planSharePerToken`, once a test sets it.

import type { FakeBotloft, Handlers } from "./fake";
import { usedTokens } from "./format";
Expand All @@ -24,6 +25,7 @@ export function usageHandlers(fake: FakeBotloft): Pick<Handlers, "usage.tokens">
archived: bot.archivedAt !== null,
turns: 0,
tokens: { input: 0, cacheWrite: 0, reloaded: 0, cacheRead: 0, output: 0 },
planShare: null,
};
byBot.set(bot.id, entry);
}
Expand All @@ -37,6 +39,10 @@ export function usageHandlers(fake: FakeBotloft): Pick<Handlers, "usage.tokens">
output: entry.tokens.output + output,
};
}
const rate = fake.planSharePerToken;
for (const entry of byBot.values()) {
entry.planShare = rate === null ? null : usedTokens(entry.tokens) * rate;
}
return [...byBot.values()].sort(
(a, b) =>
usedTokens(b.tokens) - usedTokens(a.tokens) ||
Expand Down
12 changes: 12 additions & 0 deletions app/src/lib/format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,18 @@ export function fileSize(bytes: number): string {
}

/** Tokens as the owner reads them: "850", "24.3k", "556k", "1M", "1.2M". */
/** A share of the plan, 0 to 1, as a percentage: "4.5%", "<0.1%". */
export function planPercent(share: number): string {
const percent = share * 100;
if (percent <= 0) {
return `${decimal(0, 0)}%`;
}
if (percent < 0.05) {
return `<${decimal(0.1, 1)}%`;
}
return `${decimal(percent, percent < 10 ? 1 : 0)}%`;
}

export function tokens(count: number): string {
if (count < 1000) {
return String(count);
Expand Down
7 changes: 6 additions & 1 deletion app/src/lib/protocol.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1107,7 +1107,12 @@ archived: boolean,
/**
* Turns that reported tokens.
*/
turns: number, tokens: TokenUsage, };
turns: number, tokens: TokenUsage,
/**
* About how much of the weekly plan the bot used in the period, 0 to
* 1; `null` until Botloft has learned it (spec 8.7).
*/
planShare: number | null, };

export type QuestionStatus = "open" | "answered" | "dismissed";

Expand Down
Loading
Loading