Finish the mobile performance pass

- Cache the model catalog once per user and each conversation's setting
  separately; clear both on logout and after model saves
- Compress hashed assets asynchronously with a byte budget and set Vary
- Memoize message parsing and throttle selection reads
- Debounce draft writes to localStorage
- Keep the composer editor on the page and focused, as before
- Add a bundle budget check and run it in CI
- Show a loading block while sessions load and a workspace avatar in the
  mobile conversation header
This commit is contained in:
2026-10-20 14:37:11 +01:00
parent c1a94fc9a5
commit 4f7e0a6bf0
38 changed files with 1320 additions and 334 deletions
@@ -26,8 +26,10 @@ type Params = {
* read) and its messages, the text model it runs on, its computers,
* tools and MCP servers and active task there, and whether its mic and
* read-aloud have a model to run on. The full model settings are left to
* the models modal (`api/admin/bots/harness-overview`): they are most of
* a page's weight and only that modal uses them.
* the models modal: the install-wide catalog (`api/admin/harness/catalog`)
* and the conversation's small setting
* (`api/admin/bots/harness-overview`). They are most of a page's weight
* and only that modal uses them.
* Null when the user can't chat with the bot there.
*/
export default async function grabConversationProps({
@@ -1,33 +0,0 @@
import type { BotHarnessOverviewPayload } from "@/src/types";
import grabBotHarnessOverview from "./grab-bot-harness-overview";
import grabHarnessOverview from "./grab-harness-overview";
type Params = {
/**
* A bot the caller has checked the session user chats with there.
*/
bot: { id?: number; kind?: string | null };
workspace_id?: number | null;
};
/**
* What a conversation's models modal edits: a worker's own settings, or
* for the coordinator the defaults it runs on (without the providers). In
* a workspace the workspace's own models come first, so what runs now
* shows its pick.
*/
export default async function grabConversationHarnessOverview({
bot,
workspace_id,
}: Params): Promise<BotHarnessOverviewPayload> {
if (bot.kind == "coordinator" && !workspace_id) {
const { harnesses } = await grabHarnessOverview();
return { harnesses };
}
return await grabBotHarnessOverview({
bot_id: bot.kind == "coordinator" ? undefined : bot.id,
workspace_id: workspace_id || undefined,
});
}
@@ -0,0 +1,61 @@
import type { BotHarnessSettingsPayload, HarnessSettingOverview } from "@/src/types";
import grabBotHarnessOverview from "./grab-bot-harness-overview";
import grabHarnessOverview from "./grab-harness-overview";
type Params = {
/**
* A bot the caller has checked the session user chats with there.
*/
bot: { id?: number; kind?: string | null };
workspace_id?: number | null;
};
/**
* What a conversation's models form edits, without the install-wide
* catalog: a worker's own settings, or for the coordinator the defaults it
* runs on. In a workspace the workspace's own models come first, so what
* runs now shows its pick.
*/
export default async function grabConversationHarnessSettings({
bot,
workspace_id,
}: Params): Promise<BotHarnessSettingsPayload> {
const overview =
bot.kind == "coordinator" && !workspace_id
? { harnesses: (await grabHarnessOverview()).harnesses }
: await grabBotHarnessOverview({
bot_id: bot.kind == "coordinator" ? undefined : bot.id,
workspace_id: workspace_id || undefined,
});
return { harnesses: overview.harnesses.map(stripCatalog) };
}
/**
* A harness overview item without the models and providers the catalog
* already carries. A picked model or provider the catalog no longer lists
* travels with the setting, so the form can still show it.
*/
function stripCatalog({
title,
description,
required,
automatic,
providers,
models,
...setting
}: (Awaited<ReturnType<typeof grabBotHarnessOverview>>)["harnesses"][number]): HarnessSettingOverview {
const pinned_model = setting.model_id
? models.find((model) => model.id == setting.model_id)
: undefined;
const unavailable_provider =
setting.provider_id && !providers.some((provider) => provider.id == setting.provider_id)
? { id: setting.provider_id, name: "Unavailable provider" }
: undefined;
return {
...setting,
...(pinned_model ? { pinned_model } : {}),
...(unavailable_provider ? { unavailable_provider } : {}),
};
}
@@ -0,0 +1,43 @@
import { Harnesses } from "@/src/dict/harness-dict";
import modelServesHarness from "@/src/harness/catalog/model-serves-harness";
import type {
HarnessCatalogPayload,
HarnessProviderChoice,
} from "@/src/types";
import grabHarnessModelEntries from "./grab-harness-model-entries";
/**
* Every harness's install-wide catalog: what it is for, the providers with
* a model that can do the job, and those models. One copy serves every
* bot and workspace, so the models modal caches it once per user.
*/
export default async function grabHarnessCatalog(): Promise<HarnessCatalogPayload> {
const entries = await grabHarnessModelEntries();
return {
harnesses: Harnesses.map(({ value, title, description, required, automatic }) => {
const serving = entries.filter(({ model }) =>
modelServesHarness({ model, harness: value }),
);
const providers: HarnessProviderChoice[] = [
...new Map(
serving.map(({ option }) => [
option.provider_id,
{ id: option.provider_id, name: option.provider_name },
]),
).values(),
];
return {
harness: value,
title,
description,
required,
automatic,
providers,
models: serving.map(({ option }) => option),
};
}),
};
}
@@ -2,6 +2,7 @@ import fetchApi from "@moduletrace/twui/utils/fetch/fetchApi";
import type { APIResponseObject } from "@moduletrace/bunext/types";
import type { SaveBotHarnessSettingsRequest } from "@/src/schemas/harness-schema";
import type { BotHarnessOverviewPayload } from "@/src/types";
import { clearHarnessCache } from "@/src/utils/harness-cache";
/**
* Saves a worker bot's own model settings and returns the fresh page data.
@@ -9,11 +10,15 @@ import type { BotHarnessOverviewPayload } from "@/src/types";
export default async function saveBotHarnessSettings(
body: SaveBotHarnessSettingsRequest,
) {
return await fetchApi<
const res = await fetchApi<
SaveBotHarnessSettingsRequest,
APIResponseObject<BotHarnessOverviewPayload>
>(`/api/admin/bots/save-harness-settings`, {
method: "POST",
body,
});
if (res.success) clearHarnessCache();
return res;
}
@@ -2,16 +2,21 @@ import fetchApi from "@moduletrace/twui/utils/fetch/fetchApi";
import type { APIResponseObject } from "@moduletrace/bunext/types";
import type { SaveWorkspaceHarnessSettingsRequest } from "@/src/schemas/harness-schema";
import type { BotHarnessOverviewPayload } from "@/src/types";
import { clearHarnessCache } from "@/src/utils/harness-cache";
/**
* Saves a workspace's model settings and returns the fresh page data.
*/
export default async function saveWorkspaceHarnessSettings(body: SaveWorkspaceHarnessSettingsRequest) {
return await fetchApi<
const res = await fetchApi<
SaveWorkspaceHarnessSettingsRequest,
APIResponseObject<BotHarnessOverviewPayload>
>(`/api/admin/workspaces/save-harness-settings`, {
method: "POST",
body,
});
if (res.success) clearHarnessCache();
return res;
}
+97 -23
View File
@@ -1,9 +1,12 @@
import { brotliCompressSync, constants } from "node:zlib";
import { brotliCompress, constants } from "node:zlib";
import { promisify } from "node:util";
import { AppData } from "@/src/data/app-data";
import isCompressibleType from "@/src/utils/is-compressible-type";
import isHashedAssetPath from "@/src/utils/is-hashed-asset-path";
import pickContentEncoding, { type ContentEncoding } from "@/src/utils/pick-content-encoding";
const BrotliCompress = promisify(brotliCompress);
type Params = {
req: Request;
response: Response;
@@ -14,6 +17,12 @@ type Params = {
* names are their content, so an entry never goes stale.
*/
const HashedAssetCache = new Map<string, Uint8Array<ArrayBuffer>>();
let HashedAssetCacheBytes = 0;
/**
* Compressions in progress, so parallel requests for a cold asset share
* one pass rather than each blocking a brotli run.
*/
const HashedAssetInflight = new Map<string, Promise<Uint8Array<ArrayBuffer>>>();
/**
* Compresses a text response for a client that takes it, and caches the
@@ -22,8 +31,7 @@ const HashedAssetCache = new Map<string, Uint8Array<ArrayBuffer>>();
*
* Page scripts and styles are compressed whole (brotli or gzip) and kept
* in memory; pages and API responses are gzipped as they stream. Media,
* streams, small bodies and anything already encoded go out as they
* are.
* streams, small bodies and anything already encoded go out as they are.
*/
export default async function compressResponse({ req, response }: Params): Promise<Response> {
const { pathname } = new URL(req.url);
@@ -31,6 +39,11 @@ export default async function compressResponse({ req, response }: Params): Promi
if (hashed) {
response.headers.set("Cache-Control", "public, max-age=31536000, immutable");
/**
* The same path serves a different body per encoding, so a shared
* cache must key on it even when this client gets the plain one.
*/
appendVary({ headers: response.headers });
}
if (
@@ -58,7 +71,7 @@ export default async function compressResponse({ req, response }: Params): Promi
headers.set("Content-Encoding", encoding);
headers.delete("Content-Length");
headers.delete("Content-Disposition");
headers.append("Vary", "Accept-Encoding");
appendVary({ headers });
if (hashed) {
const body = await compressHashedAsset({ pathname, encoding, response });
@@ -90,26 +103,87 @@ async function compressHashedAsset({
return cached;
}
const bytes = new Uint8Array(await response.arrayBuffer());
const body =
encoding == "br"
? new Uint8Array(
brotliCompressSync(bytes, {
params: {
[constants.BROTLI_PARAM_QUALITY]: AppData["CompressBrotliQuality"],
[constants.BROTLI_PARAM_SIZE_HINT]: bytes.length,
},
}),
)
: Bun.gzipSync(bytes, { level: 9 });
const inflight = HashedAssetInflight.get(key);
HashedAssetCache.set(key, body);
if (HashedAssetCache.size > AppData["CompressCacheMaxFiles"]) {
const oldest = HashedAssetCache.keys().next().value;
if (oldest) HashedAssetCache.delete(oldest);
if (inflight) {
response.body?.cancel().catch(() => {});
return await inflight;
}
return body;
const compression = compressBody({ encoding, response });
HashedAssetInflight.set(key, compression);
try {
const body = await compression;
rememberHashedAsset({ key, body });
return body;
} finally {
HashedAssetInflight.delete(key);
}
}
async function compressBody({
encoding,
response,
}: {
encoding: ContentEncoding;
response: Response;
}): Promise<Uint8Array<ArrayBuffer>> {
const bytes = new Uint8Array(await response.arrayBuffer());
if (encoding == "gzip") return new Uint8Array(Bun.gzipSync(bytes, { level: 9 }));
return new Uint8Array(
await BrotliCompress(bytes, {
params: {
[constants.BROTLI_PARAM_QUALITY]: AppData["CompressBrotliQuality"],
[constants.BROTLI_PARAM_SIZE_HINT]: bytes.length,
},
}),
);
}
function rememberHashedAsset({
key,
body,
}: {
key: string;
body: Uint8Array<ArrayBuffer>;
}) {
HashedAssetCache.set(key, body);
HashedAssetCacheBytes += body.byteLength;
while (
HashedAssetCache.size > AppData["CompressCacheMaxFiles"] ||
HashedAssetCacheBytes > AppData["CompressCacheMaxBytes"]
) {
const oldest = HashedAssetCache.keys().next().value;
if (!oldest) break;
const dropped = HashedAssetCache.get(oldest);
HashedAssetCache.delete(oldest);
HashedAssetCacheBytes -= dropped?.byteLength || 0;
}
}
/**
* Adds `Accept-Encoding` to `Vary` once, whatever the response already
* varies on.
*/
function appendVary({ headers }: { headers: Headers }) {
const vary = headers.get("Vary");
if (!vary) {
headers.set("Vary", "Accept-Encoding");
return;
}
if (!vary.split(",").some((part) => part.trim().toLowerCase() == "accept-encoding")) {
headers.set("Vary", `${vary}, Accept-Encoding`);
}
}