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:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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`);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user