Make pages load fast on mobile
- Compress responses: page scripts and styles once (brotli or gzip, in memory) and cached immutable; pages and API JSON gzipped as they stream - Service worker serves content-named assets cache-first - Load the models modal's data when it opens (cached in localStorage) instead of shipping the model catalog in every conversation page - Lazy-load the Tiptap composer and memoize thread messages - Import lucide icons directly; twui 0.1.4 drops by-name icons from Button - bun run start builds the pages minified before serving Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
@@ -1,11 +1,9 @@
|
||||
import type { PagePropsType } from "@/src/types";
|
||||
import type { BotTextModel, PagePropsType } from "@/src/types";
|
||||
import grabBots from "../bots/grab-bots";
|
||||
import grabActiveBotTask from "../bots/grab-active-bot-task";
|
||||
import grabBotMessages from "../bots/grab-bot-messages";
|
||||
import grabBotExtensionsOverview from "../extensions/grab-bot-extensions-overview";
|
||||
import grabBotComputers from "../computers/grab-bot-computers";
|
||||
import grabBotHarnessOverview from "../harness/grab-bot-harness-overview";
|
||||
import grabHarnessOverview from "../harness/grab-harness-overview";
|
||||
import resolveHarnessCandidates from "@/src/harness/resolve/resolve-harness-candidates";
|
||||
import grabUserWorkspaces from "../workspaces/grab-user-workspaces";
|
||||
import ensureBotSession from "./ensure-bot-session";
|
||||
@@ -25,9 +23,11 @@ type Params = {
|
||||
* The page props of a conversation (`/admin/bots/<id>` or
|
||||
* `/admin/workspaces/<id>/bots/<id>`): the rail's crew (the workspace's
|
||||
* in a workspace) and workspaces, the bot, its active session (marked
|
||||
* read) and its messages, its models, computers, tools and MCP servers
|
||||
* and active task there, and whether its mic and read-aloud have a model
|
||||
* to run on.
|
||||
* 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.
|
||||
* Null when the user can't chat with the bot there.
|
||||
*/
|
||||
export default async function grabConversationProps({
|
||||
@@ -60,21 +60,9 @@ export default async function grabConversationProps({
|
||||
|
||||
bot.unread = 0;
|
||||
|
||||
/**
|
||||
* The header's models modal: 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.
|
||||
*/
|
||||
const [bot_messages, bot_harness_overview, bot_computers, bot_task, bot_extensions, transcription, speech] = await Promise.all([
|
||||
const [bot_messages, bot_text_model, bot_computers, bot_task, bot_extensions, transcription, speech] = await Promise.all([
|
||||
grabBotMessages({ session_id: session.id!, user_id }),
|
||||
(bot.kind == "coordinator" && !scope
|
||||
? grabHarnessOverview().then(({ harnesses }) => ({ harnesses }))
|
||||
: grabBotHarnessOverview({
|
||||
bot_id: bot.kind == "coordinator" ? undefined : bot.id,
|
||||
workspace_id: scope || undefined,
|
||||
})
|
||||
).catch(() => null),
|
||||
grabTextModel({ bot_id: bot.id, workspace_id: scope }),
|
||||
grabBotComputers({ bot_id: bot.id, workspace_id: scope }),
|
||||
grabActiveBotTask({ bot_id: bot.id, session_id: session.id }),
|
||||
grabBotExtensionsOverview({ user_id, bot_id: bot.id, workspace_id: scope }),
|
||||
@@ -89,7 +77,7 @@ export default async function grabConversationProps({
|
||||
workspace: conversation.workspace,
|
||||
bot_session: session,
|
||||
bot_messages,
|
||||
bot_harness_overview,
|
||||
bot_text_model,
|
||||
bot_computers,
|
||||
bot_task,
|
||||
bot_extensions,
|
||||
@@ -105,3 +93,19 @@ async function hasHarnessModel(params: Parameters<typeof resolveHarnessCandidate
|
||||
.then((candidates) => candidates.length > 0)
|
||||
.catch(() => false);
|
||||
}
|
||||
|
||||
/**
|
||||
* The first text model the bot would try there, if any.
|
||||
*/
|
||||
async function grabTextModel(
|
||||
params: Omit<Parameters<typeof resolveHarnessCandidates>[0], "harness">,
|
||||
): Promise<BotTextModel | null> {
|
||||
const [first] = await resolveHarnessCandidates({ ...params, harness: "text" }).catch(() => []);
|
||||
|
||||
if (!first) return null;
|
||||
|
||||
return {
|
||||
name: first.model.name || first.model.slug,
|
||||
provider_name: first.provider.name || first.provider.kind,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
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,115 @@
|
||||
import { brotliCompressSync, constants } from "node:zlib";
|
||||
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";
|
||||
|
||||
type Params = {
|
||||
req: Request;
|
||||
response: Response;
|
||||
};
|
||||
|
||||
/**
|
||||
* Page scripts and styles, compressed once per path and encoding. Their
|
||||
* names are their content, so an entry never goes stale.
|
||||
*/
|
||||
const HashedAssetCache = new Map<string, Uint8Array<ArrayBuffer>>();
|
||||
|
||||
/**
|
||||
* Compresses a text response for a client that takes it, and caches the
|
||||
* content-named page scripts and styles for good (bunext gives them an
|
||||
* hour, with nothing to revalidate against).
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
export default async function compressResponse({ req, response }: Params): Promise<Response> {
|
||||
const { pathname } = new URL(req.url);
|
||||
const hashed = response.status == 200 && isHashedAssetPath({ pathname });
|
||||
|
||||
if (hashed) {
|
||||
response.headers.set("Cache-Control", "public, max-age=31536000, immutable");
|
||||
}
|
||||
|
||||
if (
|
||||
!response.body ||
|
||||
response.status != 200 ||
|
||||
response.headers.has("Content-Encoding") ||
|
||||
!isCompressibleType({ content_type: response.headers.get("Content-Type") })
|
||||
) {
|
||||
return response;
|
||||
}
|
||||
|
||||
const length = Number(response.headers.get("Content-Length"));
|
||||
|
||||
if (length && length < AppData["CompressMinBytes"]) return response;
|
||||
|
||||
const accept_encoding = req.headers.get("Accept-Encoding");
|
||||
const encoding = hashed
|
||||
? pickContentEncoding({ accept_encoding })
|
||||
: pickContentEncoding({ accept_encoding, offer: ["gzip"] });
|
||||
|
||||
if (!encoding) return response;
|
||||
|
||||
const headers = new Headers(response.headers);
|
||||
|
||||
headers.set("Content-Encoding", encoding);
|
||||
headers.delete("Content-Length");
|
||||
headers.delete("Content-Disposition");
|
||||
headers.append("Vary", "Accept-Encoding");
|
||||
|
||||
if (hashed) {
|
||||
const body = await compressHashedAsset({ pathname, encoding, response });
|
||||
|
||||
return new Response(body, { status: response.status, headers });
|
||||
}
|
||||
|
||||
return new Response(response.body.pipeThrough(new CompressionStream("gzip")), {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers,
|
||||
});
|
||||
}
|
||||
|
||||
async function compressHashedAsset({
|
||||
pathname,
|
||||
encoding,
|
||||
response,
|
||||
}: {
|
||||
pathname: string;
|
||||
encoding: ContentEncoding;
|
||||
response: Response;
|
||||
}): Promise<Uint8Array<ArrayBuffer>> {
|
||||
const key = `${encoding}:${pathname}`;
|
||||
const cached = HashedAssetCache.get(key);
|
||||
|
||||
if (cached) {
|
||||
response.body?.cancel().catch(() => {});
|
||||
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 });
|
||||
|
||||
HashedAssetCache.set(key, body);
|
||||
|
||||
if (HashedAssetCache.size > AppData["CompressCacheMaxFiles"]) {
|
||||
const oldest = HashedAssetCache.keys().next().value;
|
||||
|
||||
if (oldest) HashedAssetCache.delete(oldest);
|
||||
}
|
||||
|
||||
return body;
|
||||
}
|
||||
Reference in New Issue
Block a user