From 4f7e0a6bf0820b1443a17622c500b87b8c5ea207 Mon Sep 17 00:00:00 2001 From: Benjamin Toby Date: Tue, 20 Oct 2026 14:37:11 +0100 Subject: [PATCH] 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 --- .github/workflows/ci.yml | 6 + AGENTS.md | 5 +- package.json | 3 +- src/components/general/bot-sessions-modal.tsx | 9 +- src/data/app-data.ts | 25 ++- .../bot-sessions/grab-conversation-props.ts | 6 +- .../grab-conversation-harness-overview.ts | 33 ---- .../grab-conversation-harness-settings.ts | 61 ++++++++ .../db/harness/grab-harness-catalog.ts | 43 ++++++ .../frontend/save-bot-harness-settings.ts | 7 +- .../save-workspace-harness-settings.ts | 7 +- src/functions/server/compress-response.ts | 120 ++++++++++++--- .../(functions)/fetch-harness-catalog.ts | 17 +++ .../(functions)/fetch-harness-overview.ts | 8 +- .../[bot_id]/(hooks)/use-composer-draft.ts | 44 +++++- .../[bot_id]/(hooks)/use-harness-overview.ts | 136 +++++++++++++---- .../[bot_id]/(hooks)/use-message-selection.ts | 19 ++- .../[bot_id]/(partials)/bot-message-line.tsx | 8 +- .../[bot_id]/(partials)/bot-models-modal.tsx | 23 ++- .../(partials)/composer-input-fallback.tsx | 12 -- .../[bot_id]/(partials)/composer-input.tsx | 43 ------ .../bots/[bot_id]/(partials)/composer.tsx | 59 +++----- .../(partials)/conversation-header.tsx | 57 ++++++- .../bots/[bot_id]/(partials)/user-bubble.tsx | 8 +- .../bots/[bot_id]/(sections)/conversation.tsx | 132 +++++++++------- .../(utils)/harness-overview-cache.ts | 50 ------ .../(utils)/harness-overview-parts.test.ts | 103 +++++++++++++ .../(utils)/harness-overview-parts.ts | 84 +++++++++++ .../settings/(partials)/bot-harness-form.tsx | 14 +- .../models/(functions)/post-harness-api.ts | 7 +- .../(partials)/harness-settings-form.tsx | 12 +- src/pages/api/admin/bots/harness-overview.ts | 13 +- src/pages/api/admin/harness/catalog.ts | 42 ++++++ src/pages/auth/logout/index.tsx | 3 + src/scripts/check-bundle-size.ts | 142 ++++++++++++++++++ src/types/index.ts | 49 ++++++ src/utils/harness-cache.test.ts | 124 +++++++++++++++ src/utils/harness-cache.ts | 120 +++++++++++++++ 38 files changed, 1320 insertions(+), 334 deletions(-) delete mode 100644 src/functions/backend/db/harness/grab-conversation-harness-overview.ts create mode 100644 src/functions/backend/db/harness/grab-conversation-harness-settings.ts create mode 100644 src/functions/backend/db/harness/grab-harness-catalog.ts create mode 100644 src/pages/admin/bots/[bot_id]/(functions)/fetch-harness-catalog.ts delete mode 100644 src/pages/admin/bots/[bot_id]/(partials)/composer-input-fallback.tsx delete mode 100644 src/pages/admin/bots/[bot_id]/(partials)/composer-input.tsx delete mode 100644 src/pages/admin/bots/[bot_id]/(utils)/harness-overview-cache.ts create mode 100644 src/pages/admin/bots/[bot_id]/(utils)/harness-overview-parts.test.ts create mode 100644 src/pages/admin/bots/[bot_id]/(utils)/harness-overview-parts.ts create mode 100644 src/pages/api/admin/harness/catalog.ts create mode 100644 src/scripts/check-bundle-size.ts create mode 100644 src/utils/harness-cache.test.ts create mode 100644 src/utils/harness-cache.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e3c258f..fe51e9d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,3 +33,9 @@ jobs: - name: Test run: bun run test + + - name: Build pages + run: NODE_ENV=production bunx bunext build + + - name: Check bundle budget + run: bun run check:bundle diff --git a/AGENTS.md b/AGENTS.md index e2777fc..810ff75 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,7 +17,7 @@ This is **Cobble** (`cobble.moduletrace.org`): an open-source crew of small AI w `src/server.ts` - turns on SQLite foreign keys (`enableForeignKeys`, so the schema's cascades fire; off by default per connection), runs `bunextInit()`, starts `Bun.serve`, then starts cron: - `/media/*` short-circuits to the media server handler -- bunext's responses go through `compressResponse` (`src/functions/server/compress-response.ts`): page scripts and styles bunext names after their content (`isHashedAssetPath`) are cached `immutable` and compressed once (brotli or gzip, kept in memory); pages and API JSON are gzipped as they stream; media, streams and small bodies go out as they are. `public/sw.js` serves those same assets cache-first. Keep page props small: every page is a full server render, so anything only a modal needs loads when the modal opens (the chat header's models modal fetches `api/admin/bots/harness-overview` and keeps it in `localStorage` for `AppData.HarnessOverviewCacheMs`). Never render a lucide icon by name (twui's `LucideIcon`, a string `beforeIcon`): that ships every icon; import the icon +- bunext's responses go through `compressResponse` (`src/functions/server/compress-response.ts`): page scripts and styles bunext names after their content (`isHashedAssetPath`) are cached `immutable` and compressed once (brotli or gzip, kept in memory within `CompressCacheMaxFiles` and `CompressCacheMaxBytes`, with parallel requests sharing one pass); pages and API JSON are gzipped as they stream; media, streams and small bodies go out as they are. Every variant carries `Vary: Accept-Encoding`. `public/sw.js` serves those same assets cache-first. Keep page props small: every page is a full server render, so anything only a modal needs loads when the modal opens. The chat header's models modal fetches the install-wide catalog once (`api/admin/harness/catalog`) and the conversation's small setting separately (`api/admin/bots/harness-overview`), both kept in `localStorage` (the catalog for `AppData.HarnessCatalogCacheMs`, the setting for `AppData.HarnessOverviewCacheMs`) and cleared on logout or after a model save (`src/utils/harness-cache.ts`). `bun run check:bundle` (run in CI after a production build) fails if the conversation page's initial JS passes its budget or the full Lucide set returns. Never render a lucide icon by name (twui's `LucideIcon`, a string `beforeIcon`): that ships every icon; import the icon - `/ws` upgrades only after `userAuth` succeeds (session frozen into the socket at upgrade time); the `websocket` handler object lives in `src/websocket/` - everything else goes to `bunext.bunextRequestHandler` - `ensureDockerNetwork()` makes sure the computers' docker network (`AppData.DockerNetworkName`) exists before the server starts; without docker the process exits (computers are containers, so nothing works without it). Creating a computer's container checks it again first @@ -30,6 +30,7 @@ This is **Cobble** (`cobble.moduletrace.org`): an open-source crew of small AI w | Dev server | `bun run dev` (runs `bun src/server.ts`) | | Production | `bun run start` (builds the pages minified with `bunext build`, then `MAIN_SERVER=1 NODE_ENV=production bun src/server.ts`; bunext's start alone reuses whatever `.bunext/` holds, a dev build included) | | Type-check only | `bun run typecheck` | +| Bundle budget (after a build) | `bun run check:bundle` | | Run tests | `bun run test` | | Regenerate favicon/brand/PWA assets | `bun run gen:favicon` (runs `src/scripts/generate-brand-assets.ts`) | | DB schema (tables) | `bun run db:schema` | @@ -92,7 +93,7 @@ Inspect live data with `bun llm-code-assistant/query-db.ts ""` - a read-onl - **Coordinator:** the install has exactly one `kind: coordinator` bot, the super admin's alone, that coordinates the crew and delegates to every worker. It is created at signup (`bun run seed`) and, as a fallback, by the super admin's `/admin/bots`, all via `ensureCoordinatorBot` (`src/functions/backend/db/bots/ensure-coordinator-bot.ts`, idempotent). Name in `CoordinatorBotName` (`src/dict/bot-dict.ts`). Its default prompt is `CoordinatorSystemPrompt` (`src/data/coordinator-system-prompt.ts`): attached at creation, and backfilled onto a coordinator whose prompt is empty, never over an edited one. - **Routes:** `/admin/bots` ensures the coordinator and redirects the super admin to its chat (`/admin/bots/1` on a fresh install), anyone else to their latest bot, else their first workspace, else an empty page; `/admin/bots/[bot_id]` renders the conversation (header, thread and composer, the last two held to 760px; there is no right panel); `/admin/bots/[bot_id]/models` is a worker bot's own model settings, for super admins and admins (the coordinator's redirects to its chat); `/admin/workspaces/[workspace_id]` is a workspace's dashboard (`(sections)/workspace-dashboard.tsx`: read-only picture, name, description and context, latest activity, bots with their per-workspace instructions and memory, computers; its header links into `/admin/settings/workspaces/[workspace_id]`, whose `workspace-overview.tsx` has the editable groups) and `/admin/workspaces/[workspace_id]/bots/[bot_id]` is the same `Conversation` section scoped to the workspace (`pageProps.workspace`; only the coordinator and member bots). All render in the full-bleed `src/layouts/bots/` layout (rail of conversations + user popover), which `__root.tsx` uses for every `/admin/*` page. - **Server:** `src/functions/backend/db/bots/` (`grab-bots`, `grab-bot-messages`, `grab-active-bot-task`, `grab-user-bot`, `create-bot`, `insert-user-message`). `api/admin/bots/send-message.ts` posts a user message to a bot the session user chats with (zod: `src/schemas/bot-schema.ts`), queues the bot's reply (`enqueueBotReply`) and wakes the queue (`src/bots-queue/wake-bots-queue.ts`; never import `src/bots-queue/index.ts` from a page or API route, it pulls the bunext runtime into the page bundles). `upload-attachment.ts` stores one file for the next message (private `media` row, paradigm `bot-attachment`, type from `attachmentFileKind`, documents get `text_content`, limits in `AppData.BotAttachment*`) and `send-message` takes their `media_ids` (checked to be the user's own by `grabAttachableMedia`), linked in `bot_message_media`; messages carry `attachments` (`BotMessageWithAttachments`). `mark-read.ts` clears a session's unread when a message arrives in the open conversation; `session-messages.ts` returns a session's latest messages, which the open conversation polls every `AppData.BotReplyCatchUpMs` while a reply is due (`use-reply-catch-up`), so a reply whose live push was missed (socket down) still lands without a reload (replayed as a `server:bot-message`, so the rail and unread take it too); `create-bot.ts` adds a worker (and puts it in a workspace when given one); `save-harness-settings.ts` saves a worker bot's models. -- **Composer:** a Tiptap editor (`(hooks)/use-composer-editor.ts`, `(partials)/composer-editor.tsx`), not a textarea, loaded lazily after the page by `(partials)/composer-input.tsx` (Tiptap is the page's heaviest code; `composer-input-fallback.tsx` holds its place, and the composer reaches its commands through a ref, so nothing else may import Tiptap statically), that reads and writes markdown (`@tiptap/markdown`), so drafts (`useComposerDraft`) and sent messages are markdown. Bold, italic, strike, code, lists, quotes and links (no headings), from shortcuts, markdown typing or the selection bubble (`composer-format-bubble.tsx`, items in `(data)/composer-formats.ts`). `@` mentions a bot and `#` a workspace (`(data)/composer-mention-kinds.ts`, menu `composer-mention-list.tsx` above the input, items from `CrewBotsContext` and `CrewWorkspacesContext` via `searchMentionItems`), shown as chips (`composer-mention-chip.tsx`) and written as `[bot:ID]` / `[workspace:ID]` (`(utils)/mention-markdown.ts`, `src/utils/workspace-ref.ts`). Enter sends, Shift+Enter breaks the line; in a list or code block Enter is the editor's and Mod+Enter sends. Selecting text in a message (`data-quotable`, `use-message-selection`) shows a Quote button (`quote-selection-button.tsx`) that inserts it as a blockquote at the cursor (`ComposerHandle.insertQuote`). Before a reply, `grabMessageRefNames` names the refs in the user's messages that the user reaches, and `botMessagesToHarness` adds a `[Mentions: ...]` line after each one (`describeMessageRefs`). `@tiptap/react` needs `use-sync-external-store`, which is CommonJS and calls `require("react")`; bunext keeps React external in page bundles, so the app installs an ESM stand-in from `packages/use-sync-external-store-esm` (a `file:` dependency plus an override; the Dockerfile copies `packages/` before `bun install`). +- **Composer:** a Tiptap editor (`(hooks)/use-composer-editor.ts`, `(partials)/composer-editor.tsx`), not a textarea, loaded with the page and focused automatically, that reads and writes markdown (`@tiptap/markdown`), so drafts and sent messages are markdown. Drafts are written to `localStorage` on a 250ms debounce (`useComposerDraft`), flushed on unmount. Bold, italic, strike, code, lists, quotes and links (no headings), from shortcuts, markdown typing or the selection bubble (`composer-format-bubble.tsx`, items in `(data)/composer-formats.ts`). `@` mentions a bot and `#` a workspace (`(data)/composer-mention-kinds.ts`, menu `composer-mention-list.tsx` above the input, items from `CrewBotsContext` and `CrewWorkspacesContext` via `searchMentionItems`), shown as chips (`composer-mention-chip.tsx`) and written as `[bot:ID]` / `[workspace:ID]` (`(utils)/mention-markdown.ts`, `src/utils/workspace-ref.ts`). Enter sends, Shift+Enter breaks the line; in a list or code block Enter is the editor's and Mod+Enter sends. Selecting text in a message (`data-quotable`, `use-message-selection`) shows a Quote button (`quote-selection-button.tsx`) that inserts it as a blockquote at the cursor (`ComposerHandle.insertQuote`). Before a reply, `grabMessageRefNames` names the refs in the user's messages that the user reaches, and `botMessagesToHarness` adds a `[Mentions: ...]` line after each one (`describeMessageRefs`). `@tiptap/react` needs `use-sync-external-store`, which is CommonJS and calls `require("react")`; bunext keeps React external in page bundles, so the app installs an ESM stand-in from `packages/use-sync-external-store-esm` (a `file:` dependency plus an override; the Dockerfile copies `packages/` before `bun install`). - **Message rendering:** bot messages are markdown, rendered with `react-markdown` + `remark-gfm` (raw HTML never rendered) through the twui-based map in `[bot_id]/(data)/message-markdown-components.tsx` (`markdown-link`, `markdown-list`, `markdown-code-block` partials). A bot names a crew member as `[bot:ID]` (`src/utils/bot-ref.ts`, linked to `#bot-ID` by `link-computer-refs.ts`): `(partials)/bot-ref-chip.tsx` shows its avatar and name, linked to its conversation (in the open workspace when it's in the crew there), from the live crew (`CrewBotsContext`), so a bot created in that reply links too; the coordinator is told to write them (`CoordinatorReplyNote`), and `find_bot` and `create_bot` return them. A workspace is named as `[workspace:ID]` (`#workspace-ID`, `(partials)/workspace-ref-chip.tsx`, from `CrewWorkspacesContext`). User messages are markdown too, rendered in the ink bubble by `user-bubble.tsx` with `UserMarkdownComponents` and `remark-breaks` (a single line break stays one, so older plain messages read as typed). - **Replies and reactions:** every bot message has a row under it (`(partials)/message-actions.tsx`, handlers from `BotMessageActionsContext`) to reply or react; the user's own messages only show the bot's reactions, read-only. Replying quotes the message above the composer (`composer-reply-preview.tsx`) and the sent one carries `bot_messages.reply_to_message_id` (not a foreign key: a deleted quote only drops it); `send-message` takes any message of the same conversation (the user's, same bot and workspace, any session, so a rollover or a moved-on thread still sends), and the conversation drops a picked quote when its session changes. Messages carry `reply_to` (`grabMessagesReplies`, also on relayed messages), shown by `message-reply-quote.tsx`, and the bot reads it as `[Replying to ...]` before the user's turn (`botMessagesToHarness`). Reactions are a fixed set (`BotMessageReactions` in `src/dict/bot-dict.ts`) in `bot_message_reactions` (one row per message, emoji and user, or bot); `api/admin/bots/react-message.ts` toggles the user's own on a bot message in their sessions (`grabUserBotMessage`, `toggleMessageReaction`), and every bot gets `react_to_message` (`src/harness/tools/bot/react-to-message-tool.ts`, chat replies and tasks: a message in the session it answers, the user's latest by default). Both push the message's fresh reactions (`grabMessagesReactions`, `mine` for the session user) to the session's user as `server:bot-reaction` (`BotMessageReactionData`), which replaces the message's own; `grabBotMessages` adds them when given `user_id`. Reactions aren't relayed between processes (production runs one). - **Attachments and thinking:** the composer uploads files as they're picked, pasted or dropped (`use-composer-attachments`), shows them as chips, and sends once none is still uploading; the thread shows images as thumbnails and other files as chips. Replies read the files of the last `BotReplyAttachmentMessages` messages with any (`load-attachment-parts.ts`: images and audio as parts, documents as their text up to `BotAttachmentMaxTextChars`, videos as a note); older ones are only named. Tasks don't get the files yet. While the user has the last word in a session (every user message queues a reply, so this needs no face and shows even while the bot works in another session) or the bot waits for a model (`BotWaitingStatusLine`) or a task for its computer (`BotComputerBusyStatusLine`), `BotThinkingRow` sits under the last message; while a task runs it reads " is working…" with the task's latest step, and a reply working through tools shows its latest step under "is thinking…" (`use-reply-progress`). A coordinator with no workers gets `CoordinatorSoloNote` instead of `CoordinatorReplyNote`, so it never claims to have handed work to a worker. Live notices come from the process that ran the job at once, and from every other process through the live relay. diff --git a/package.json b/package.json index 7fc9188..bba6323 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,8 @@ "seed:bots": "bun src/scripts/seed-bots.ts", "start": "NODE_ENV=production bunx bunext build && MAIN_SERVER=1 NODE_ENV=production bun src/server.ts", "typecheck": "tsc --noEmit", - "test": "bun test" + "test": "bun test", + "check:bundle": "bun src/scripts/check-bundle-size.ts" }, "devDependencies": { "@next/mdx": "^16.3.0", diff --git a/src/components/general/bot-sessions-modal.tsx b/src/components/general/bot-sessions-modal.tsx index 4366dc2..e7fbe97 100644 --- a/src/components/general/bot-sessions-modal.tsx +++ b/src/components/general/bot-sessions-modal.tsx @@ -11,6 +11,7 @@ import type { BotWithLastMessage } from "@/src/types"; import useBotSessions from "@/src/hooks/use-bot-sessions"; import BotSessionRow from "./bot-session-row"; import HeaderIconButton from "./header-icon-button"; +import LoadingBlock from "@moduletrace/twui/elements/LoadingBlock"; type Props = { bot: BotWithLastMessage; @@ -68,7 +69,7 @@ export default function BotSessionsModal({ } open={open} setOpen={setOpen} - className="max-w-lg bg-paper p-6 xl:p-10 pt-14!" + className="max-w-lg bg-paper p-6 xl:p-10 py-14!" > @@ -124,13 +125,15 @@ export default function BotSessionsModal({ } /> ))} - {sessions.sessions.length || sessions.loading ? null : ( + {sessions.loading ? ( + + ) : sessions.sessions.length ? ( {sessions.query.trim() ? `No session matches "${sessions.query.trim()}".` : "No sessions yet."} - )} + ) : null} ); diff --git a/src/data/app-data.ts b/src/data/app-data.ts index 72047f5..557008e 100644 --- a/src/data/app-data.ts +++ b/src/data/app-data.ts @@ -12,11 +12,18 @@ export const AppData = { */ ComposerDraftStorageKey: `${SiteData["SiteSlug"]}_composer_draft`, /** - * Prefix of the chat header's models modal data in `localStorage`, one - * per bot and workspace, and how long it is shown before it is fetched - * again. + * Prefix of the chat header's model data in `localStorage`: one catalog + * per user, and one small setting per bot and workspace. + * `HarnessCatalogCacheMs` is how long the catalog is shown before it is + * fetched again, `HarnessOverviewCacheMs` the setting. */ - HarnessOverviewStorageKey: `${SiteData["SiteSlug"]}_harness_overview`, + HarnessCacheStorageKey: `${SiteData["SiteSlug"]}_harness_cache`, + HarnessCatalogCacheMs: 60 * 60 * 1000, + /** + * The per-conversation key an older version wrote. Removed on logout + * and by `clearHarnessCache`. + */ + LegacyHarnessOverviewStorageKey: `${SiteData["SiteSlug"]}_harness_overview`, HarnessOverviewCacheMs: 10 * 60 * 1000, ServerHealthMessage: "Server Running Well", /** @@ -366,12 +373,14 @@ export const AppData = { /** * Brotli quality for page scripts and styles, compressed once per * file and kept in memory (`compressResponse`). 11 is about 25 times - * slower for 10% less. + * slower for 10% less; 5 is close to 9's size at half the time. */ - CompressBrotliQuality: 9, + CompressBrotliQuality: 5, /** - * How many compressed page scripts and styles stay in memory. A - * rebuild names its files anew, so the oldest go first. + * How many compressed page scripts and styles, and how many bytes of + * them, stay in memory. A rebuild names its files anew, so the oldest + * go first. */ CompressCacheMaxFiles: 600, + CompressCacheMaxBytes: 32 * 1024 * 1024, } as const; diff --git a/src/functions/backend/db/bot-sessions/grab-conversation-props.ts b/src/functions/backend/db/bot-sessions/grab-conversation-props.ts index 346d0c2..88994e7 100644 --- a/src/functions/backend/db/bot-sessions/grab-conversation-props.ts +++ b/src/functions/backend/db/bot-sessions/grab-conversation-props.ts @@ -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({ diff --git a/src/functions/backend/db/harness/grab-conversation-harness-overview.ts b/src/functions/backend/db/harness/grab-conversation-harness-overview.ts deleted file mode 100644 index 808b345..0000000 --- a/src/functions/backend/db/harness/grab-conversation-harness-overview.ts +++ /dev/null @@ -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 { - 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, - }); -} diff --git a/src/functions/backend/db/harness/grab-conversation-harness-settings.ts b/src/functions/backend/db/harness/grab-conversation-harness-settings.ts new file mode 100644 index 0000000..a7fad7c --- /dev/null +++ b/src/functions/backend/db/harness/grab-conversation-harness-settings.ts @@ -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 { + 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>)["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 } : {}), + }; +} diff --git a/src/functions/backend/db/harness/grab-harness-catalog.ts b/src/functions/backend/db/harness/grab-harness-catalog.ts new file mode 100644 index 0000000..45a4770 --- /dev/null +++ b/src/functions/backend/db/harness/grab-harness-catalog.ts @@ -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 { + 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), + }; + }), + }; +} diff --git a/src/functions/frontend/save-bot-harness-settings.ts b/src/functions/frontend/save-bot-harness-settings.ts index 687859e..dc3f65b 100644 --- a/src/functions/frontend/save-bot-harness-settings.ts +++ b/src/functions/frontend/save-bot-harness-settings.ts @@ -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 >(`/api/admin/bots/save-harness-settings`, { method: "POST", body, }); + + if (res.success) clearHarnessCache(); + + return res; } diff --git a/src/functions/frontend/save-workspace-harness-settings.ts b/src/functions/frontend/save-workspace-harness-settings.ts index 2e39e26..f456649 100644 --- a/src/functions/frontend/save-workspace-harness-settings.ts +++ b/src/functions/frontend/save-workspace-harness-settings.ts @@ -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 >(`/api/admin/workspaces/save-harness-settings`, { method: "POST", body, }); + + if (res.success) clearHarnessCache(); + + return res; } diff --git a/src/functions/server/compress-response.ts b/src/functions/server/compress-response.ts index a07fa04..e22a54d 100644 --- a/src/functions/server/compress-response.ts +++ b/src/functions/server/compress-response.ts @@ -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>(); +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>>(); /** * Compresses a text response for a client that takes it, and caches the @@ -22,8 +31,7 @@ const HashedAssetCache = new Map>(); * * 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 { 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> { + 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; +}) { + 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`); + } } diff --git a/src/pages/admin/bots/[bot_id]/(functions)/fetch-harness-catalog.ts b/src/pages/admin/bots/[bot_id]/(functions)/fetch-harness-catalog.ts new file mode 100644 index 0000000..a712bf2 --- /dev/null +++ b/src/pages/admin/bots/[bot_id]/(functions)/fetch-harness-catalog.ts @@ -0,0 +1,17 @@ +import fetchApi from "@moduletrace/twui/utils/fetch/fetchApi"; +import type { APIResponseObject } from "@moduletrace/bunext/types"; +import type { HarnessCatalogPayload } from "@/src/types"; + +/** + * The install-wide models catalog, shared by every conversation's models + * modal and cached once per user. + */ +export default async function fetchHarnessCatalog() { + return await fetchApi< + Record, + APIResponseObject + >(`/api/admin/harness/catalog`, { + method: "POST", + body: {}, + }); +} diff --git a/src/pages/admin/bots/[bot_id]/(functions)/fetch-harness-overview.ts b/src/pages/admin/bots/[bot_id]/(functions)/fetch-harness-overview.ts index 120ddb5..44bc796 100644 --- a/src/pages/admin/bots/[bot_id]/(functions)/fetch-harness-overview.ts +++ b/src/pages/admin/bots/[bot_id]/(functions)/fetch-harness-overview.ts @@ -1,16 +1,16 @@ import fetchApi from "@moduletrace/twui/utils/fetch/fetchApi"; import type { APIResponseObject } from "@moduletrace/bunext/types"; import type { BotConversationRequest } from "@/src/schemas/bot-schema"; -import type { BotHarnessOverviewPayload } from "@/src/types"; +import type { BotHarnessSettingsPayload } from "@/src/types"; /** - * What a conversation's models modal edits: per harness, the setting, the - * models it can pick and what it runs on now. + * What a conversation's models form edits, without the install-wide + * catalog: the setting, what runs now, and what the default runs on. */ export default async function fetchHarnessOverview({ bot_id, workspace_id }: BotConversationRequest) { return await fetchApi< BotConversationRequest, - APIResponseObject + APIResponseObject >(`/api/admin/bots/harness-overview`, { method: "POST", body: { bot_id, workspace_id }, diff --git a/src/pages/admin/bots/[bot_id]/(hooks)/use-composer-draft.ts b/src/pages/admin/bots/[bot_id]/(hooks)/use-composer-draft.ts index 96f8f5b..d09b864 100644 --- a/src/pages/admin/bots/[bot_id]/(hooks)/use-composer-draft.ts +++ b/src/pages/admin/bots/[bot_id]/(hooks)/use-composer-draft.ts @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import composerDraftKey from "../(utils)/composer-draft-key"; type Params = { @@ -6,17 +6,46 @@ type Params = { workspace_id?: number | null; }; +/** + * How long typing rests before the draft is written. `localStorage` is + * synchronous, so writing on every keystroke makes a phone's input wait. + */ +const WriteDelayMs = 250; + /** * The composer's text (markdown), kept in `localStorage` per bot and * workspace until it's sent, so a reload or leaving the conversation * doesn't lose it. Read after mount, since the page renders on the server - * first; `loaded` turns true once it has been, for that `key`. + * first; `loaded` turns true once it has been, for that `key`. Writes are + * debounced and flushed on unmount or when the conversation changes. */ export default function useComposerDraft({ bot_id, workspace_id }: Params) { const key = composerDraftKey({ bot_id, workspace_id, part: "text" }); const [draft, setDraft] = useState({ key, text: "", loaded: false }); + const timer = useRef | null>(null); + const pending = useRef<{ key: string; text: string } | null>(null); + + const flush = () => { + if (timer.current) { + clearTimeout(timer.current); + timer.current = null; + } + + const next = pending.current; + + pending.current = null; + + if (!next) return; + + try { + if (next.text) localStorage.setItem(next.key, next.text); + else localStorage.removeItem(next.key); + } catch {} + }; useEffect(() => { + flush(); + let text = ""; try { @@ -26,13 +55,16 @@ export default function useComposerDraft({ bot_id, workspace_id }: Params) { setDraft({ key, text, loaded: true }); }, [key]); + useEffect(() => () => flush(), []); + const setText = (text: string) => { setDraft({ key, text, loaded: true }); - try { - if (text) localStorage.setItem(key, text); - else localStorage.removeItem(key); - } catch {} + pending.current = { key, text }; + + if (timer.current) clearTimeout(timer.current); + + timer.current = setTimeout(flush, WriteDelayMs); }; const current = draft.key == key; diff --git a/src/pages/admin/bots/[bot_id]/(hooks)/use-harness-overview.ts b/src/pages/admin/bots/[bot_id]/(hooks)/use-harness-overview.ts index 19045ea..dc64e8c 100644 --- a/src/pages/admin/bots/[bot_id]/(hooks)/use-harness-overview.ts +++ b/src/pages/admin/bots/[bot_id]/(hooks)/use-harness-overview.ts @@ -1,13 +1,26 @@ -import { useEffect, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { AppData } from "@/src/data/app-data"; -import type { HarnessOverview } from "@/src/types"; +import type { + HarnessCatalog, + HarnessOverview, + HarnessSettingOverview, +} from "@/src/types"; +import { + readHarnessCatalogCache, + readHarnessSettingsCache, + writeHarnessCatalogCache, + writeHarnessSettingsCache, +} from "@/src/utils/harness-cache"; +import fetchHarnessCatalog from "../(functions)/fetch-harness-catalog"; import fetchHarnessOverview from "../(functions)/fetch-harness-overview"; import { - readHarnessOverviewCache, - writeHarnessOverviewCache, -} from "../(utils)/harness-overview-cache"; + harnessCatalogFromOverview, + harnessSettingsFromOverview, + mergeHarnessOverview, +} from "../(utils)/harness-overview-parts"; type Params = { + user_id: number; bot_id: number; workspace_id?: number | null; /** @@ -18,55 +31,118 @@ type Params = { }; /** - * The models modal's data for a conversation. Shows what `localStorage` - * last had at once, and fetches it again when there is none or it is - * older than `AppData.HarnessOverviewCacheMs`. `setHarnesses` takes a - * save's fresh data and keeps it. + * The models modal's data for a conversation. The install-wide catalog is + * cached once per user; the conversation's own setting is cached per bot + * and workspace, and is small. Either shows what `localStorage` last had + * at once and is fetched again when it is older than its + * `AppData.Harness*CacheMs`. `setHarnesses` takes a save's fresh data and + * keeps it. */ -export default function useHarnessOverview({ bot_id, workspace_id, enabled }: Params) { - const [harnesses, setState] = useState(); +export default function useHarnessOverview({ + user_id, + bot_id, + workspace_id, + enabled, +}: Params) { + const [catalog, setCatalog] = useState(); + const [settings, setSettings] = useState(); const [error, setError] = useState(); /** - * Counts fetched updates, so a form showing the cached data remounts - * with the fresh. + * Counts fetched settings, so a form showing cached data remounts with + * the fresh when it isn't being edited. */ const [version, setVersion] = useState(0); useEffect(() => { - if (!enabled) return; + if (!enabled || !user_id || !bot_id) return; let cancelled = false; - const cached = readHarnessOverviewCache({ bot_id, workspace_id }); + const cached_catalog = readHarnessCatalogCache({ user_id }); + const cached_settings = readHarnessSettingsCache({ user_id, bot_id, workspace_id }); - if (cached) setState(cached.harnesses); - if (cached && Date.now() - cached.at < AppData.HarnessOverviewCacheMs) return; + if (cached_catalog) setCatalog(cached_catalog.data.harnesses); + if (cached_settings) setSettings(cached_settings.data.harnesses); + + const now = Date.now(); + const stale = (at: number | undefined, max_age_ms: number) => + !at || now - at >= max_age_ms; + const need_catalog = stale(cached_catalog?.at, AppData.HarnessCatalogCacheMs); + const need_settings = stale(cached_settings?.at, AppData.HarnessOverviewCacheMs); + + if (!need_catalog && !need_settings) return; setError(undefined); - fetchHarnessOverview({ bot_id, workspace_id }) - .then((res) => { + const loadCatalog = async () => { + try { + const res = await fetchHarnessCatalog(); + if (cancelled) return; if (res.success && res.singleRes) { - setState(res.singleRes.harnesses); - setVersion((prev) => prev + 1); - writeHarnessOverviewCache({ bot_id, workspace_id, harnesses: res.singleRes.harnesses }); - } else if (!cached) { + setCatalog(res.singleRes.harnesses); + writeHarnessCatalogCache({ user_id, data: res.singleRes }); + } else if (!cached_catalog) { setError(res.msg || "Couldn't load the models."); } - }) - .catch(() => { - if (!cancelled && !cached) setError("Couldn't load the models."); - }); + } catch { + if (!cancelled && !cached_catalog) setError("Couldn't load the models."); + } + }; + + const loadSettings = async () => { + try { + const res = await fetchHarnessOverview({ bot_id, workspace_id }); + + if (cancelled) return; + + if (res.success && res.singleRes) { + setSettings(res.singleRes.harnesses); + setVersion((prev) => prev + 1); + writeHarnessSettingsCache({ + user_id, + bot_id, + workspace_id, + data: res.singleRes, + }); + } else if (!cached_settings) { + setError(res.msg || "Couldn't load the models."); + } + } catch { + if (!cancelled && !cached_settings) setError("Couldn't load the models."); + } + }; + + void Promise.all([need_catalog ? loadCatalog() : null, need_settings ? loadSettings() : null]); return () => { cancelled = true; }; - }, [enabled, bot_id, workspace_id]); + }, [enabled, user_id, bot_id, workspace_id]); + const harnesses = useMemo( + () => mergeHarnessOverview({ catalog, settings }), + [catalog, settings], + ); + + /** + * A save answers with the full overview: keep both parts of the cache + * current, so the next open is instant and the catalog is not fetched + * again. + */ const setHarnesses = (fresh: HarnessOverview[]) => { - setState(fresh); - writeHarnessOverviewCache({ bot_id, workspace_id, harnesses: fresh }); + const next_catalog = harnessCatalogFromOverview({ harnesses: fresh }); + const next_settings = harnessSettingsFromOverview({ harnesses: fresh }); + + setCatalog(next_catalog); + setSettings(next_settings); + writeHarnessCatalogCache({ user_id, data: { harnesses: next_catalog } }); + writeHarnessSettingsCache({ + user_id, + bot_id, + workspace_id, + data: { harnesses: next_settings }, + }); }; return { harnesses, error, version, setHarnesses }; diff --git a/src/pages/admin/bots/[bot_id]/(hooks)/use-message-selection.ts b/src/pages/admin/bots/[bot_id]/(hooks)/use-message-selection.ts index 1298033..43945de 100644 --- a/src/pages/admin/bots/[bot_id]/(hooks)/use-message-selection.ts +++ b/src/pages/admin/bots/[bot_id]/(hooks)/use-message-selection.ts @@ -48,14 +48,29 @@ export default function useMessageSelection({ ref }: Params) { setSelection({ text, rect: range.getBoundingClientRect() }); }; + /** + * Reading a selection on every scroll event is costly on a phone; + * one read per frame is enough. + */ + let frame = 0; + const onScroll = () => { + if (frame) return; + + frame = requestAnimationFrame(() => { + frame = 0; + read(); + }); + }; + document.addEventListener("selectionchange", read); - ref.current?.addEventListener("scroll", read, { passive: true }); + ref.current?.addEventListener("scroll", onScroll, { passive: true }); const scroller = ref.current; return () => { document.removeEventListener("selectionchange", read); - scroller?.removeEventListener("scroll", read); + scroller?.removeEventListener("scroll", onScroll); + if (frame) cancelAnimationFrame(frame); }; }, [ref]); diff --git a/src/pages/admin/bots/[bot_id]/(partials)/bot-message-line.tsx b/src/pages/admin/bots/[bot_id]/(partials)/bot-message-line.tsx index 88099e0..8799a52 100644 --- a/src/pages/admin/bots/[bot_id]/(partials)/bot-message-line.tsx +++ b/src/pages/admin/bots/[bot_id]/(partials)/bot-message-line.tsx @@ -15,6 +15,12 @@ import MessageReplyQuote from "./message-reply-quote"; import NeedsYouChip from "./needs-you-chip"; import SpeakMessageButton from "./speak-message-button"; +/** + * Kept module-level: a new array on every render makes `react-markdown` + * parse the message again. + */ +const RemarkPlugins = [remarkGfm]; + type Props = { message: BotMessageWithAttachments; bot_name: string; @@ -52,7 +58,7 @@ function BotMessageLine({ message, bot_name, at }: Props) { className="w-full gap-2.5! items-start break-words" > {linkComputerRefs({ text: message.text || "" })} diff --git a/src/pages/admin/bots/[bot_id]/(partials)/bot-models-modal.tsx b/src/pages/admin/bots/[bot_id]/(partials)/bot-models-modal.tsx index 16d04f9..6adba43 100644 --- a/src/pages/admin/bots/[bot_id]/(partials)/bot-models-modal.tsx +++ b/src/pages/admin/bots/[bot_id]/(partials)/bot-models-modal.tsx @@ -1,4 +1,4 @@ -import { useContext, useState } from "react"; +import { useContext, useEffect, useState } from "react"; import Modal from "@moduletrace/twui/elements/Modal"; import H2 from "@moduletrace/twui/layout/H2"; import Span from "@moduletrace/twui/layout/Span"; @@ -41,6 +41,15 @@ export default function BotModelsModal({ bot, workspace, onSaved }: Props) { * The pointer or focus reached the icon: start loading before the click. */ const [warm, setWarm] = useState(false); + /** + * The form has edits that a background refresh must not replace. + */ + const [dirty, setDirty] = useState(false); + /** + * The key the form remounts on. It only moves while the form is clean, + * so a fetch that lands mid-edit leaves the edits alone. + */ + const [form_key, setFormKey] = useState(0); const in_workspace = Boolean(workspace?.id); const is_coordinator = bot.kind == "coordinator" && !in_workspace; @@ -50,11 +59,20 @@ export default function BotModelsModal({ bot, workspace, onSaved }: Props) { const name = bot.name || "This bot"; const { harnesses, error, version, setHarnesses } = useHarnessOverview({ + user_id: Number(pageProps?.user?.id || 0), bot_id: Number(bot.id), workspace_id: workspace?.id || null, enabled: (open || warm) && can_edit, }); + useEffect(() => { + if (!dirty) setFormKey(version); + }, [dirty, version]); + + useEffect(() => { + if (!open) setDirty(false); + }, [open]); + if (!bot.id) return null; const target = ( @@ -107,10 +125,11 @@ export default function BotModelsModal({ bot, workspace, onSaved }: Props) { {harnesses?.length ? ( { setHarnesses(fresh); onSaved(fresh); diff --git a/src/pages/admin/bots/[bot_id]/(partials)/composer-input-fallback.tsx b/src/pages/admin/bots/[bot_id]/(partials)/composer-input-fallback.tsx deleted file mode 100644 index ca2453f..0000000 --- a/src/pages/admin/bots/[bot_id]/(partials)/composer-input-fallback.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import Span from "@moduletrace/twui/layout/Span"; - -/** - * Holds the editor's place, the same size, while it loads. - */ -export default function ComposerInputFallback({ placeholder }: { placeholder: string }) { - return ( - - {placeholder} - - ); -} diff --git a/src/pages/admin/bots/[bot_id]/(partials)/composer-input.tsx b/src/pages/admin/bots/[bot_id]/(partials)/composer-input.tsx deleted file mode 100644 index 422b53c..0000000 --- a/src/pages/admin/bots/[bot_id]/(partials)/composer-input.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import { useEffect, type RefObject } from "react"; -import type { Editor } from "@tiptap/react"; -import useComposerEditor, { type ComposerMentionMenu } from "../(hooks)/use-composer-editor"; -import ComposerEditor from "./composer-editor"; - -export type ComposerInputApi = ReturnType; - -type Props = Parameters[0] & { - /** - * Gets the editor's commands (`setMarkdown`, `clear`, `insertText`, - * `insertQuote`) for the composer around it. - */ - api_ref: RefObject; - onEditor: (editor: Editor | null) => void; - onMenu: (menu: ComposerMentionMenu | null) => void; - invalid?: boolean; - title?: string; -}; - -/** - * The composer's Tiptap editor, loaded on its own after the page (the - * composer imports it lazily: Tiptap is the heaviest code on the page). - * Hands its editor, mention menu and commands up to the composer. - */ -export default function ComposerInput({ api_ref, onEditor, onMenu, invalid, title, ...params }: Props) { - const input = useComposerEditor(params); - - api_ref.current = input; - - useEffect(() => { - onEditor(input.editor); - }, [input.editor]); - - useEffect(() => { - onMenu(input.menu); - }, [input.menu]); - - useEffect(() => () => { - api_ref.current = null; - }, []); - - return ; -} diff --git a/src/pages/admin/bots/[bot_id]/(partials)/composer.tsx b/src/pages/admin/bots/[bot_id]/(partials)/composer.tsx index 66ff069..ae8ee14 100644 --- a/src/pages/admin/bots/[bot_id]/(partials)/composer.tsx +++ b/src/pages/admin/bots/[bot_id]/(partials)/composer.tsx @@ -1,5 +1,4 @@ -import { lazy, Suspense, useContext, useEffect, useImperativeHandle, useRef, useState, type Ref } from "react"; -import type { Editor } from "@tiptap/react"; +import { useContext, useEffect, useImperativeHandle, useRef, useState, type Ref } from "react"; import Row from "@moduletrace/twui/layout/Row"; import Stack from "@moduletrace/twui/layout/Stack"; import P from "@moduletrace/twui/layout/P"; @@ -15,25 +14,18 @@ import sendBotMessage from "../(functions)/send-bot-message"; import transcribeVoice from "../(functions)/transcribe-voice"; import useComposerAttachments from "../(hooks)/use-composer-attachments"; import useComposerDraft from "../(hooks)/use-composer-draft"; -import type { ComposerMentionMenu } from "../(hooks)/use-composer-editor"; +import useComposerEditor from "../(hooks)/use-composer-editor"; import useVoiceRecorder from "../(hooks)/use-voice-recorder"; import useOnlineStatus from "@/src/hooks/use-online-status"; import ComposerAttachButton from "./composer-attach-button"; import ComposerAttachments from "./composer-attachments"; -import ComposerInputFallback from "./composer-input-fallback"; -import type { ComposerInputApi } from "./composer-input"; +import ComposerEditor from "./composer-editor"; import ComposerMentionList from "./composer-mention-list"; import ComposerReplyPreview from "./composer-reply-preview"; import ComposerSendButton from "./composer-send-button"; import ComposerTools from "./composer-tools"; import ComposerVoiceButton from "./composer-voice-button"; -/** - * Tiptap is the heaviest code on the page, so the editor loads on its own - * once the page is up; a stand-in holds its place until then. - */ -const ComposerInput = lazy(() => import("./composer-input")); - /** * What the conversation can do to the composer from outside it. */ @@ -68,7 +60,7 @@ type Props = { }; /** - * A Tiptap editor (`useComposerEditor`, loaded lazily by `ComposerInput`) writing markdown: Enter sends, + * A Tiptap editor (`useComposerEditor`) writing markdown: Enter sends, * Shift+Enter adds a line, a selection can be formatted, `@` mentions a * bot and `#` a workspace. Grows to eight lines, then scrolls. * Its top rule lines up with the rail's user-row rule; picked files sit @@ -98,16 +90,17 @@ export default function Composer({ const workspaces = useContext(CrewWorkspacesContext); const send_ref = useRef<() => void>(() => {}); - const input = useRef(null); - const [editor, setEditor] = useState(null); - const [menu, setMenu] = useState(null); - const placeholder = `Message ${bot.name}`; + const input = useComposerEditor({ + placeholder: `Message ${bot.name}`, + bots, + workspaces, + onChange: ({ markdown }) => setText(markdown), + onSubmit: () => send_ref.current(), + onFiles: files.add, + }); + const editor = input.editor; - useImperativeHandle( - handle_ref, - () => ({ insertQuote: ({ text }) => input.current?.insertQuote({ text }) }), - [], - ); + useImperativeHandle(handle_ref, () => ({ insertQuote: input.insertQuote }), [input.insertQuote]); /** * The saved draft goes into the editor once per conversation, after @@ -122,7 +115,7 @@ export default function Composer({ /** Out of React's render, since mention chips mount as it loads. */ queueMicrotask(() => { - input.current?.setMarkdown({ markdown: text }); + input.setMarkdown({ markdown: text }); editor.commands.focus("end"); }); }, [editor, draft.loaded, draft.key]); @@ -145,7 +138,7 @@ export default function Composer({ return; } - input.current?.insertText({ text: res.singleRes.text }); + input.insertText({ text: res.singleRes.text }); }, }); @@ -178,7 +171,7 @@ export default function Composer({ } setText(""); - input.current?.clear(); + input.clear(); files.clear(); onSent({ message: res.singleRes }); } @@ -211,7 +204,7 @@ export default function Composer({ onCancel={onCancelReply} /> ) : null} - {menu ? : null} + {input.menu ? : null} { const target = e.target as HTMLElement; @@ -226,21 +219,7 @@ export default function Composer({ disabled={files.full} onFiles={files.add} /> - }> - setText(markdown)} - onSubmit={() => send_ref.current()} - onFiles={files.add} - invalid={Boolean(error)} - title={error} - /> - + {recorder.supported ? ( {bot.name} + + {workspace?.id ? ( + + + + {workspace.name || "Workspace"} + + + ) : null} + + {workspace?.id ? ( - + ) : null} - - + + { - const current = harnesses.find((h) => h.harness == "text")?.current; + const current = harnesses.find( + (h) => h.harness == "text", + )?.current; - setModel(current ? { name: current.name, provider_name: current.provider_name } : null); + setModel( + current + ? { + name: current.name, + provider_name: current.provider_name, + } + : null, + ); }} /> diff --git a/src/pages/admin/bots/[bot_id]/(partials)/user-bubble.tsx b/src/pages/admin/bots/[bot_id]/(partials)/user-bubble.tsx index 5adf171..2cb6aca 100644 --- a/src/pages/admin/bots/[bot_id]/(partials)/user-bubble.tsx +++ b/src/pages/admin/bots/[bot_id]/(partials)/user-bubble.tsx @@ -6,6 +6,12 @@ import linkComputerRefs from "@/src/utils/link-computer-refs"; import { UserMarkdownComponents } from "../(data)/user-markdown-components"; import { QuotableAttribute } from "../(hooks)/use-message-selection"; +/** + * Kept module-level: a new array on every render makes `react-markdown` + * parse the message again. + */ +const RemarkPlugins = [remarkGfm, remarkBreaks]; + /** * Ink bubble with paper text; inverts in dark mode. The text is markdown * (GFM; raw HTML is never rendered) as the composer writes it, a single @@ -18,7 +24,7 @@ export default function UserBubble({ text }: { text: string }) { {...{ [QuotableAttribute]: "" }} className="max-w-[80%] min-w-0 gap-2.5! items-stretch rounded-default-lg bg-ink text-on-ink px-4 py-3 text-sm leading-5 break-words" > - + {linkComputerRefs({ text })} diff --git a/src/pages/admin/bots/[bot_id]/(sections)/conversation.tsx b/src/pages/admin/bots/[bot_id]/(sections)/conversation.tsx index 400eb1a..7a12f23 100644 --- a/src/pages/admin/bots/[bot_id]/(sections)/conversation.tsx +++ b/src/pages/admin/bots/[bot_id]/(sections)/conversation.tsx @@ -45,9 +45,13 @@ export default function Conversation() { const [messages, setMessages] = useState( pageProps?.bot_messages || [], ); - const [session_id, setSessionId] = useState(pageProps?.bot_session?.id || null); + const [session_id, setSessionId] = useState( + pageProps?.bot_session?.id || null, + ); const [has_other_unread, setHasOtherUnread] = useState(false); - const [reply_to, setReplyTo] = useState(null); + const [reply_to, setReplyTo] = useState( + null, + ); /** * A quote picked in one session doesn't follow the thread to another. @@ -73,7 +77,9 @@ export default function Conversation() { computers: pageProps?.bot_computers || [], }); - const speak = useSpeakMessage({ enabled: Boolean(pageProps?.bot_voice?.speech) }); + const speak = useSpeakMessage({ + enabled: Boolean(pageProps?.bot_voice?.speech), + }); const progress = useTaskProgress({ bot_id: bot?.id || 0, @@ -100,7 +106,10 @@ export default function Conversation() { * moves the thread onto it. */ const startsNewSession = (message: BotMessageWithAttachments) => - Boolean(message.session_id && (!session_id || Number(message.session_id) > session_id)); + Boolean( + message.session_id && + (!session_id || Number(message.session_id) > session_id), + ); useServerEvent({ event: "server:bot-message", @@ -140,10 +149,15 @@ export default function Conversation() { * A reaction toggled here or in another tab: the pushed list replaces * the message's own. */ - const applyReactions = ({ message_id, reactions }: BotMessageReactionData) => { + const applyReactions = ({ + message_id, + reactions, + }: BotMessageReactionData) => { setMessages((prev) => prev.map((message) => - Number(message.id) == message_id ? { ...message, reactions } : message, + Number(message.id) == message_id + ? { ...message, reactions } + : message, ), ); }; @@ -168,7 +182,8 @@ export default function Conversation() { */ const message_actions = useMemo( () => ({ - onReply: ({ message }: { message: BotMessageWithAttachments }) => setReplyTo(message), + onReply: ({ message }: { message: BotMessageWithAttachments }) => + setReplyTo(message), onReact: reactToMessage, }), [], @@ -178,54 +193,63 @@ export default function Conversation() { return ( - - - - - - - composer.current?.insertQuote({ text })} - /> - setReplyTo(null)} - onSent={({ message }) => { - setReplyTo(null); + + + + + + + + composer.current?.insertQuote({ text }) + } + /> + setReplyTo(null)} + onSent={({ message }) => { + setReplyTo(null); - if (startsNewSession(message) && message.session_id != session_id) { - setSessionId(Number(message.session_id)); - setMessages([message]); - return; - } - - setMessages((prev) => - prev.some((m) => m.id == message.id) - ? prev - : [...prev, message], - ); - }} - /> - - - - - - + if ( + startsNewSession(message) && + message.session_id != session_id + ) { + setSessionId( + Number(message.session_id), + ); + setMessages([message]); + return; + } + + setMessages((prev) => + prev.some((m) => m.id == message.id) + ? prev + : [...prev, message], + ); + }} + /> + + + + + + ); } diff --git a/src/pages/admin/bots/[bot_id]/(utils)/harness-overview-cache.ts b/src/pages/admin/bots/[bot_id]/(utils)/harness-overview-cache.ts deleted file mode 100644 index d3f6980..0000000 --- a/src/pages/admin/bots/[bot_id]/(utils)/harness-overview-cache.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { AppData } from "@/src/data/app-data"; -import type { HarnessOverview } from "@/src/types"; - -/** - * Bumped when `HarnessOverview` changes shape, so an older cache is - * fetched again instead of read. - */ -const CacheVersion = 1; - -type Key = { - bot_id: number; - workspace_id?: number | null; -}; - -export type CachedHarnessOverview = { - v: number; - /** - * Unix ms it was fetched. - */ - at: number; - harnesses: HarnessOverview[]; -}; - -function cacheKey({ bot_id, workspace_id }: Key) { - return `${AppData.HarnessOverviewStorageKey}:${bot_id}:${workspace_id || 0}`; -} - -/** - * The models modal's data last fetched for this conversation, from - * `localStorage`; null when there is none or it can't be read. - */ -export function readHarnessOverviewCache(key: Key): CachedHarnessOverview | null { - try { - const cached = JSON.parse(localStorage.getItem(cacheKey(key)) || "null"); - - return cached?.v == CacheVersion && - Array.isArray(cached.harnesses) && - typeof cached.at == "number" - ? cached - : null; - } catch { - return null; - } -} - -export function writeHarnessOverviewCache({ harnesses, ...key }: Key & { harnesses: HarnessOverview[] }) { - try { - localStorage.setItem(cacheKey(key), JSON.stringify({ v: CacheVersion, at: Date.now(), harnesses })); - } catch {} -} diff --git a/src/pages/admin/bots/[bot_id]/(utils)/harness-overview-parts.test.ts b/src/pages/admin/bots/[bot_id]/(utils)/harness-overview-parts.test.ts new file mode 100644 index 0000000..bae5e89 --- /dev/null +++ b/src/pages/admin/bots/[bot_id]/(utils)/harness-overview-parts.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, test } from "bun:test"; +import type { HarnessOverview } from "@/src/types"; +import { + harnessCatalogFromOverview, + harnessSettingsFromOverview, + mergeHarnessOverview, +} from "./harness-overview-parts"; + +const overview: HarnessOverview = { + harness: "text", + title: "Text", + description: "Chats.", + required: true, + automatic: true, + model_id: 4, + provider_id: 1, + providers: [{ id: 1, name: "OpenRouter" }], + fallback: false, + enabled: true, + options: { voice: "alloy" }, + models: [ + { + id: 4, + provider_id: 1, + provider_name: "OpenRouter", + slug: "deepseek", + name: "DeepSeek", + free: true, + }, + ], + current: { + id: 4, + provider_id: 1, + provider_name: "OpenRouter", + slug: "deepseek", + name: "DeepSeek", + free: true, + source: "pinned", + }, + candidate_count: 2, + inherit: false, +}; + +describe("harness overview parts", () => { + test("splits the install-wide catalog from the conversation's setting", () => { + const [catalog] = harnessCatalogFromOverview({ harnesses: [overview] }); + const [setting] = harnessSettingsFromOverview({ harnesses: [overview] }); + + expect(catalog).toMatchObject({ + harness: "text", + title: "Text", + providers: overview.providers, + models: overview.models, + }); + expect(setting).not.toHaveProperty("models"); + expect(setting).not.toHaveProperty("providers"); + expect(setting).toMatchObject({ + harness: "text", + model_id: 4, + provider_id: 1, + fallback: false, + enabled: true, + }); + }); + + test("merges a cached catalog and setting back into the form's shape", () => { + const [catalog] = harnessCatalogFromOverview({ harnesses: [overview] }); + const [setting] = harnessSettingsFromOverview({ harnesses: [overview] }); + + expect( + mergeHarnessOverview({ catalog: [catalog!], settings: [setting!] }), + ).toEqual([overview]); + }); + + test("waits for both parts", () => { + const [catalog] = harnessCatalogFromOverview({ harnesses: [overview] }); + + expect(mergeHarnessOverview({ catalog: [catalog!], settings: [] })).toBeUndefined(); + expect(mergeHarnessOverview({ catalog: [], settings: [] })).toBeUndefined(); + }); + + test("keeps a picked model the catalog no longer lists", () => { + const [catalog] = harnessCatalogFromOverview({ + harnesses: [{ ...overview, models: [] }], + }); + const [setting] = harnessSettingsFromOverview({ harnesses: [overview] }); + const [merged] = mergeHarnessOverview({ catalog: [catalog!], settings: [setting!] })!; + + expect(setting?.pinned_model).toEqual(overview.models[0]); + expect(merged?.models).toEqual(overview.models); + }); + + test("keeps a provider the catalog no longer lists", () => { + const without_providers = { ...overview, providers: [], models: [] }; + const [catalog] = harnessCatalogFromOverview({ harnesses: [without_providers] }); + const [setting] = harnessSettingsFromOverview({ harnesses: [without_providers] }); + const [merged] = mergeHarnessOverview({ catalog: [catalog!], settings: [setting!] })!; + + expect(merged?.providers).toEqual([ + { id: 1, name: "Unavailable provider" }, + ]); + }); +}); diff --git a/src/pages/admin/bots/[bot_id]/(utils)/harness-overview-parts.ts b/src/pages/admin/bots/[bot_id]/(utils)/harness-overview-parts.ts new file mode 100644 index 0000000..efab714 --- /dev/null +++ b/src/pages/admin/bots/[bot_id]/(utils)/harness-overview-parts.ts @@ -0,0 +1,84 @@ +import type { + HarnessCatalog, + HarnessOverview, + HarnessSettingOverview, +} from "@/src/types"; + +type Harnesses = { + harnesses: HarnessOverview[]; +}; + +/** + * The install-wide part of a model overview: everything except the bot's + * own settings. + */ +export function harnessCatalogFromOverview({ harnesses }: Harnesses): HarnessCatalog[] { + return harnesses.map(({ harness, title, description, required, automatic, providers, models }) => ({ + harness, + title, + description, + required, + automatic, + providers, + models, + })); +} + +/** + * A bot's (or workspace's) part of a model overview: the settings, what + * runs now, and what the default runs on. A picked model or provider the + * catalog no longer lists travels with it, so it isn't lost. + */ +export function harnessSettingsFromOverview({ harnesses }: Harnesses): HarnessSettingOverview[] { + return harnesses.map(({ title, description, required, automatic, providers, models, ...setting }) => { + 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 } : {}), + }; + }); +} + +/** + * A catalog and a conversation's settings as the models form wants them: + * the catalog's list of models and providers under the setting. Undefined + * until both parts are there. + */ +export function mergeHarnessOverview({ + catalog, + settings, +}: { + catalog?: HarnessCatalog[] | null; + settings?: HarnessSettingOverview[] | null; +}): HarnessOverview[] | undefined { + if (!catalog?.length || !settings?.length) return undefined; + + const by_harness = new Map(catalog.map((entry) => [entry.harness, entry])); + + return settings.flatMap((setting) => { + const entry = by_harness.get(setting.harness); + + if (!entry) return []; + + const { pinned_model, unavailable_provider, ...rest } = setting; + const models = + pinned_model && !entry.models.some((model) => model.id == pinned_model.id) + ? [...entry.models, pinned_model] + : entry.models; + const providers = + unavailable_provider && + !entry.providers.some((provider) => provider.id == unavailable_provider.id) + ? [...entry.providers, unavailable_provider] + : entry.providers; + + return [{ ...entry, ...rest, models, providers }]; + }); +} diff --git a/src/pages/admin/settings/(partials)/bot-harness-form.tsx b/src/pages/admin/settings/(partials)/bot-harness-form.tsx index 308b5bf..2baf618 100644 --- a/src/pages/admin/settings/(partials)/bot-harness-form.tsx +++ b/src/pages/admin/settings/(partials)/bot-harness-form.tsx @@ -21,6 +21,11 @@ type Props = { * Gets the fresh harnesses after each save. */ onSaved?: (harnesses: HarnessOverview[]) => void; + /** + * Whether the form has unsaved edits, so a background refresh can + * leave them alone. + */ + onDirtyChange?: (dirty: boolean) => void; }; /** @@ -29,7 +34,13 @@ type Props = { * runs on the defaults, so it saves the defaults themselves, which only a * super admin can. */ -export default function BotHarnessForm({ bot, workspace_id, harnesses: initial, onSaved }: Props) { +export default function BotHarnessForm({ + bot, + workspace_id, + harnesses: initial, + onSaved, + onDirtyChange, +}: Props) { const [harnesses, setHarnesses] = useState(initial); /** * Remounts the form when the data changes under it. @@ -49,6 +60,7 @@ export default function BotHarnessForm({ bot, workspace_id, harnesses: initial, key={version} harnesses={harnesses} notice={notice} + onDirtyChange={onDirtyChange} save={async ({ settings }) => { if (workspace_id) { const res = await saveWorkspaceHarnessSettings({ diff --git a/src/pages/admin/settings/models/(functions)/post-harness-api.ts b/src/pages/admin/settings/models/(functions)/post-harness-api.ts index b4eb96a..eab7e75 100644 --- a/src/pages/admin/settings/models/(functions)/post-harness-api.ts +++ b/src/pages/admin/settings/models/(functions)/post-harness-api.ts @@ -1,6 +1,7 @@ import fetchApi from "@moduletrace/twui/utils/fetch/fetchApi"; import type { APIResponseObject } from "@moduletrace/bunext/types"; import type { HarnessOverviewPayload } from "@/src/types"; +import { clearHarnessCache } from "@/src/utils/harness-cache"; type Params = { /** @@ -15,11 +16,15 @@ type Params = { * data in `singleRes`, so the page updates in place. */ export default async function postHarnessApi({ path, body }: Params) { - return await fetchApi< + const res = await fetchApi< Record, APIResponseObject >(`/api/admin/harness/${path}`, { method: "POST", body, }); + + if (res.success) clearHarnessCache(); + + return res; } diff --git a/src/pages/admin/settings/models/(partials)/harness-settings-form.tsx b/src/pages/admin/settings/models/(partials)/harness-settings-form.tsx index 541fbcb..695eade 100644 --- a/src/pages/admin/settings/models/(partials)/harness-settings-form.tsx +++ b/src/pages/admin/settings/models/(partials)/harness-settings-form.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useEffect, useState } from "react"; import Form from "@moduletrace/twui/form/Form"; import useFormInit from "@/src/hooks/use-form-init"; import type { HarnessOverview, HarnessSettingsFormObject } from "@/src/types"; @@ -20,6 +20,11 @@ type Props = { * The last save's message, kept by the page across the remount. */ notice?: string; + /** + * Whether there are unsaved edits, so a background refresh can leave + * them alone. + */ + onDirtyChange?: (dirty: boolean) => void; }; /** @@ -30,6 +35,7 @@ export default function HarnessSettingsForm({ harnesses, save, notice: saved_notice, + onDirtyChange, }: Props) { const saved = toHarnessForm({ harnesses }); /** @@ -47,6 +53,10 @@ export default function HarnessSettingsForm({ const changed = changedHarnessSettings({ form, saved }); const dirty = changed.length > 0; + useEffect(() => { + onDirtyChange?.(dirty); + }, [dirty, onDirtyChange]); + const discard = () => { setForm(saved); setStatus({}); diff --git a/src/pages/api/admin/bots/harness-overview.ts b/src/pages/api/admin/bots/harness-overview.ts index d662e02..371b99e 100644 --- a/src/pages/api/admin/bots/harness-overview.ts +++ b/src/pages/api/admin/bots/harness-overview.ts @@ -1,8 +1,8 @@ import userAuth from "@/src/functions/backend/auth/user-auth"; import grabUserConversation from "@/src/functions/backend/db/bot-sessions/grab-user-conversation"; -import grabConversationHarnessOverview from "@/src/functions/backend/db/harness/grab-conversation-harness-overview"; +import grabConversationHarnessSettings from "@/src/functions/backend/db/harness/grab-conversation-harness-settings"; import { BotConversationRequestSchema } from "@/src/schemas/bot-schema"; -import type { BotHarnessOverviewPayload } from "@/src/types"; +import type { BotHarnessSettingsPayload } from "@/src/types"; import AppError from "@/src/utils/app-error"; import parseWithSchema from "@/src/utils/parse-with-schema"; import canManageCrew from "@/src/utils/rbac/can-manage-crew"; @@ -14,12 +14,13 @@ import type { } from "@moduletrace/bunext/types"; /** - * What a conversation's models modal edits, loaded when it opens: the + * What a conversation's models form edits, loaded when it opens: the * coordinator's defaults (super admins), or a worker's or the workspace's - * models (super admins and admins). + * models (super admins and admins). The install-wide catalog comes from + * `api/admin/harness/catalog`, so it is cached once rather than per bot. */ export const handler: BunextAPIRouteHandler< - APIResponseObject + APIResponseObject > = async ({ req, body }) => { try { if (req.method != "POST") { @@ -50,7 +51,7 @@ export const handler: BunextAPIRouteHandler< return { success: true, - singleRes: await grabConversationHarnessOverview({ + singleRes: await grabConversationHarnessSettings({ bot: conversation.bot, workspace_id: scope, }), diff --git a/src/pages/api/admin/harness/catalog.ts b/src/pages/api/admin/harness/catalog.ts new file mode 100644 index 0000000..8043f0e --- /dev/null +++ b/src/pages/api/admin/harness/catalog.ts @@ -0,0 +1,42 @@ +import userAuth from "@/src/functions/backend/auth/user-auth"; +import grabHarnessCatalog from "@/src/functions/backend/db/harness/grab-harness-catalog"; +import type { HarnessCatalogPayload } from "@/src/types"; +import AppError from "@/src/utils/app-error"; +import canManageCrew from "@/src/utils/rbac/can-manage-crew"; +import safeErrorMessage from "@/src/utils/safe-error-message"; +import type { + APIResponseObject, + BunextAPIRouteHandler, +} from "@moduletrace/bunext/types"; + +/** + * The install-wide models catalog: per harness, the providers and models + * it can pick. The chat header's models modal fetches it once and keeps it + * in `localStorage`, so every bot and workspace reuses it. + */ +export const handler: BunextAPIRouteHandler< + APIResponseObject +> = async ({ req }) => { + try { + if (req.method != "POST") { + throw new AppError(`Method not allowed`); + } + + const { user, user_types } = await userAuth({ req }); + + if (!user?.logged_in_status || !user.id) { + return { success: false, msg: `Unauthorized`, logoutUser: true }; + } + + if (!canManageCrew({ user_types })) { + throw new AppError(`Only an admin can see the model catalog.`); + } + + return { + success: true, + singleRes: await grabHarnessCatalog(), + }; + } catch (error: unknown) { + return { success: false, msg: safeErrorMessage({ error }) }; + } +}; diff --git a/src/pages/auth/logout/index.tsx b/src/pages/auth/logout/index.tsx index 3cae01c..d749e52 100644 --- a/src/pages/auth/logout/index.tsx +++ b/src/pages/auth/logout/index.tsx @@ -10,9 +10,12 @@ import Paper from "@moduletrace/twui/elements/Paper"; import Container from "@moduletrace/twui/layout/Container"; import Center from "@moduletrace/twui/layout/Center"; import { SiteData } from "@/src/data/site-data"; +import { clearHarnessCache } from "@/src/utils/harness-cache"; export default function LogoutPage() { useEffect(() => { + clearHarnessCache(); + setTimeout(() => { window.location.pathname = "/auth/login"; }, 1000); diff --git a/src/scripts/check-bundle-size.ts b/src/scripts/check-bundle-size.ts new file mode 100644 index 0000000..8d82323 --- /dev/null +++ b/src/scripts/check-bundle-size.ts @@ -0,0 +1,142 @@ +import { existsSync, readFileSync, readdirSync, statSync } from "fs"; +import path from "path"; +import { gzipSync } from "node:zlib"; + +/** + * Fails when the conversation page's initial JavaScript (or CSS) grows past + * its budget, or when the full Lucide icon set comes back. Run after + * `NODE_ENV=production bunx bunext build`. + * + * The editor is part of the page by design, so the budget allows Tiptap; + * the Lucide guard is what keeps the old icon bundle from returning. + */ +const MaxInitialJsBytes = 450 * 1024; +const MaxSingleFileBytes = 300 * 1024; +const MaxCssBytes = 40 * 1024; + +const pages_dir = path.resolve(".bunext/public/pages"); +const conversation_dir = path.join(pages_dir, "admin/bots/[bot_id]"); + +if (!existsSync(conversation_dir)) { + console.error( + `No conversation build at ${path.relative(process.cwd(), conversation_dir)}. Run \`NODE_ENV=production bunx bunext build\` first.`, + ); + process.exit(1); +} + +const entry = readdirSync(conversation_dir) + .filter((name) => name.endsWith(".js")) + .map((name) => path.join(conversation_dir, name)) + .sort((a, b) => statSync(b).mtimeMs - statSync(a).mtimeMs)[0]; + +if (!entry) { + console.error(`No page script in ${conversation_dir}.`); + process.exit(1); +} + +const sizes = new Map(); +const gzipSize = (file: string) => { + const cached = sizes.get(file); + + if (cached !== undefined) return cached; + + const size = gzipSync(readFileSync(file)).byteLength; + + sizes.set(file, size); + + return size; +}; + +const static_files = new Set(); +const queue = [entry]; + +while (queue.length) { + const file = queue.pop()!; + + if (static_files.has(file)) continue; + + static_files.add(file); + + const source = readFileSync(file, "utf8"); + + for (const match of source.matchAll(/(?:from|import)\s*["']([^"']+)["']/g)) { + const target = resolve({ file, specifier: match[1]! }); + + if (target) queue.push(target); + } + +} + +const initial_bytes = sumBytes([...static_files]); +const largest = [...static_files].sort((a, b) => gzipSize(b) - gzipSize(a))[0]; +const largest_bytes = largest ? gzipSize(largest) : 0; +const css_bytes = readdirSync(conversation_dir) + .filter((name) => name.endsWith(".css")) + .reduce((total, name) => total + gzipSize(path.join(conversation_dir, name)), 0); +/** + * Every icon imported is listed in the module comments. A handful is + * normal; the whole catalog (about 1,900) is the regression this catches. + */ +const lucide_icons = new Set(); + +for (const file of static_files) { + for (const match of readFileSync(file, "utf8").matchAll( + /lucide-react\/dist\/esm\/icons\/([a-z0-9-]+)\.mjs/g, + )) { + lucide_icons.add(match[1]!); + } +} + +report({ label: "initial JS", bytes: initial_bytes, budget: MaxInitialJsBytes }); +report({ label: "largest file", bytes: largest_bytes, budget: MaxSingleFileBytes }); +report({ label: "CSS", bytes: css_bytes, budget: MaxCssBytes }); + +let failed = false; + +failed = check({ + label: "initial JS", + bytes: initial_bytes, + budget: MaxInitialJsBytes, +}) || failed; +failed = check({ + label: "largest file", + bytes: largest_bytes, + budget: MaxSingleFileBytes, +}) || failed; +failed = check({ label: "CSS", bytes: css_bytes, budget: MaxCssBytes }) || failed; + +if (lucide_icons.size > 200) { + console.error(`The full Lucide icon set is in the page bundle (${lucide_icons.size} icons).`); + failed = true; +} + +if (failed) process.exit(1); + +console.log("Bundle budget holds."); + +function resolve({ file, specifier }: { file: string; specifier: string }) { + if (!specifier.startsWith(".")) return null; + + const target = path.resolve(path.dirname(file), specifier); + + return existsSync(target) ? target : null; +} + +function sumBytes(files: string[]) { + return files.reduce((total, file) => total + gzipSize(file), 0); +} + +function report({ label, bytes, budget }: { label: string; bytes: number; budget: number }) { + console.log(`${label}: ${kb(bytes)} (budget ${kb(budget)})`); +} + +function check({ label, bytes, budget }: { label: string; bytes: number; budget: number }) { + if (bytes <= budget) return false; + + console.error(`${label} is over budget: ${kb(bytes)} > ${kb(budget)}.`); + return true; +} + +function kb(bytes: number) { + return `${Math.round(bytes / 1024)} KB`; +} diff --git a/src/types/index.ts b/src/types/index.ts index 114d715..711dfd2 100755 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -340,6 +340,55 @@ export type HarnessOverviewPayload = { harnesses: HarnessOverview[]; }; +/** + * The install-wide part of a harness overview: what it is for and the + * models it can pick. Every bot and workspace shares it, so the models + * modal caches one copy per user. + */ +export type HarnessCatalog = Pick< + HarnessOverview, + | "harness" + | "title" + | "description" + | "required" + | "automatic" + | "providers" + | "models" +>; + +export type HarnessCatalogPayload = { + harnesses: HarnessCatalog[]; +}; + +/** + * The per-bot (or per-workspace) part of a harness overview: the setting, + * what it runs on now, and what the default runs on. + */ +export type HarnessSettingOverview = Omit< + HarnessOverview, + | "title" + | "description" + | "required" + | "automatic" + | "providers" + | "models" +> & { + /** + * A picked model the catalog no longer lists (its provider dropped it). + * The merge adds it back so the form can show and keep it. + */ + pinned_model?: HarnessModelOption; + /** + * The provider the setting is limited to when the catalog has no model + * from it, so the form still shows the limit. + */ + unavailable_provider?: HarnessProviderChoice; +}; + +export type BotHarnessSettingsPayload = { + harnesses: HarnessSettingOverview[]; +}; + /** * An env as the Environment settings page sees it. `value` is sent only * for non-secret envs; secrets are replaced, never read back. diff --git a/src/utils/harness-cache.test.ts b/src/utils/harness-cache.test.ts new file mode 100644 index 0000000..8576101 --- /dev/null +++ b/src/utils/harness-cache.test.ts @@ -0,0 +1,124 @@ +import { beforeEach, describe, expect, test } from "bun:test"; +import { AppData } from "@/src/data/app-data"; +import type { + BotHarnessSettingsPayload, + HarnessCatalogPayload, +} from "@/src/types"; +import { + clearHarnessCache, + readHarnessCatalogCache, + readHarnessSettingsCache, + writeHarnessCatalogCache, + writeHarnessSettingsCache, +} from "./harness-cache"; + +class MemoryStorage { + private store = new Map(); + + get length() { + return this.store.size; + } + + key(index: number) { + return [...this.store.keys()][index] ?? null; + } + + getItem(key: string) { + return this.store.get(key) ?? null; + } + + setItem(key: string, value: string) { + this.store.set(key, value); + } + + removeItem(key: string) { + this.store.delete(key); + } + + clear() { + this.store.clear(); + } +} + +const catalog: HarnessCatalogPayload = { + harnesses: [ + { + harness: "text", + title: "Text", + description: "Chats.", + required: true, + automatic: true, + providers: [{ id: 1, name: "OpenRouter" }], + models: [], + }, + ], +}; + +const settings: BotHarnessSettingsPayload = { + harnesses: [ + { + harness: "text", + fallback: false, + enabled: true, + options: {}, + candidate_count: 2, + }, + ], +}; + +let storage: MemoryStorage; + +beforeEach(() => { + storage = new MemoryStorage(); + Object.defineProperty(globalThis, "localStorage", { + value: storage as unknown as Storage, + configurable: true, + }); +}); + +describe("harness cache", () => { + test("round-trips the catalog per user", () => { + writeHarnessCatalogCache({ user_id: 1, data: catalog }); + + expect(readHarnessCatalogCache({ user_id: 1 })?.data).toEqual(catalog); + expect(readHarnessCatalogCache({ user_id: 2 })).toBeNull(); + }); + + test("round-trips a conversation's settings per user, bot and workspace", () => { + writeHarnessSettingsCache({ + user_id: 1, + bot_id: 7, + workspace_id: 3, + data: settings, + }); + + expect(readHarnessSettingsCache({ user_id: 1, bot_id: 7, workspace_id: 3 })?.data).toEqual( + settings, + ); + expect(readHarnessSettingsCache({ user_id: 2, bot_id: 7, workspace_id: 3 })).toBeNull(); + expect(readHarnessSettingsCache({ user_id: 1, bot_id: 8, workspace_id: 3 })).toBeNull(); + }); + + test("ignores another schema version", () => { + storage.setItem( + `${AppData.HarnessCacheStorageKey}:catalog:1`, + JSON.stringify({ v: 0, at: Date.now(), data: catalog }), + ); + + expect(readHarnessCatalogCache({ user_id: 1 })).toBeNull(); + }); + + test("clears the new and the older per-conversation keys only", () => { + writeHarnessCatalogCache({ user_id: 1, data: catalog }); + writeHarnessSettingsCache({ user_id: 1, bot_id: 7, data: settings }); + storage.setItem(`${AppData.LegacyHarnessOverviewStorageKey}:7:0`, "{}"); + storage.setItem(`${AppData.ComposerDraftStorageKey}:7`, "draft"); + + clearHarnessCache(); + + expect(readHarnessCatalogCache({ user_id: 1 })).toBeNull(); + expect(readHarnessSettingsCache({ user_id: 1, bot_id: 7 })).toBeNull(); + expect(storage.getItem(`${AppData.LegacyHarnessOverviewStorageKey}:7:0`)).toBeNull(); + expect(storage.getItem(`${AppData.ComposerDraftStorageKey}:7`)).toBe("draft"); + }); +}); diff --git a/src/utils/harness-cache.ts b/src/utils/harness-cache.ts new file mode 100644 index 0000000..4907717 --- /dev/null +++ b/src/utils/harness-cache.ts @@ -0,0 +1,120 @@ +import { AppData } from "@/src/data/app-data"; +import type { + BotHarnessSettingsPayload, + HarnessCatalogPayload, +} from "@/src/types"; + +/** + * Bumped when a cached model payload changes shape, so an older cache is + * fetched again instead of read. + */ +const CacheVersion = 1; + +type CacheEnvelope = { + v: number; + /** + * Unix ms it was fetched. + */ + at: number; + data: T; +}; + +type UserKey = { + user_id: number | string; +}; + +type SettingsKey = UserKey & { + bot_id: number | string; + workspace_id?: number | null; +}; + +function catalogKey({ user_id }: UserKey) { + return `${AppData.HarnessCacheStorageKey}:catalog:${user_id}`; +} + +function settingsKey({ user_id, bot_id, workspace_id }: SettingsKey) { + return `${AppData.HarnessCacheStorageKey}:settings:${user_id}:${bot_id}:${workspace_id || 0}`; +} + +function read(key: string): CacheEnvelope | null { + try { + const cached = JSON.parse(localStorage.getItem(key) || "null"); + + return cached?.v == CacheVersion && + typeof cached.at == "number" && + Array.isArray(cached.data?.harnesses) + ? cached + : null; + } catch { + return null; + } +} + +function write(key: string, data: T) { + try { + localStorage.setItem( + key, + JSON.stringify({ v: CacheVersion, at: Date.now(), data } satisfies CacheEnvelope), + ); + } catch {} +} + +/** + * The install-wide model catalog this user last fetched, from + * `localStorage`; null when there is none or it can't be read. + */ +export function readHarnessCatalogCache(key: UserKey) { + return read(catalogKey(key)); +} + +export function writeHarnessCatalogCache({ + user_id, + data, +}: UserKey & { data: HarnessCatalogPayload }) { + write(catalogKey({ user_id }), data); +} + +/** + * One conversation's model setting this user last fetched, from + * `localStorage`; null when there is none or it can't be read. + */ +export function readHarnessSettingsCache(key: SettingsKey) { + return read(settingsKey(key)); +} + +export function writeHarnessSettingsCache({ + user_id, + bot_id, + workspace_id, + data, +}: SettingsKey & { data: BotHarnessSettingsPayload }) { + write(settingsKey({ user_id, bot_id, workspace_id }), data); +} + +/** + * Drops every model cache, including the per-conversation keys an older + * version wrote. Called on logout and after model settings change + * elsewhere, so the modal can't show a stale pick. + */ +export function clearHarnessCache() { + const prefixes = [ + AppData.HarnessCacheStorageKey, + AppData.LegacyHarnessOverviewStorageKey, + ]; + + try { + const keys: string[] = []; + + for (let index = 0; index < localStorage.length; index++) { + const key = localStorage.key(index); + + if (key) keys.push(key); + } + + for (const key of keys) { + if (prefixes.some((prefix) => key.startsWith(prefix))) { + localStorage.removeItem(key); + } + } + } catch {} +}