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:
2026-10-20 13:41:45 +01:00
co-authored by Claude Opus 5.5
parent 3b355d68bc
commit c1a94fc9a5
36 changed files with 840 additions and 147 deletions
+3 -2
View File
@@ -17,6 +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: `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 - `/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
- `/ws` upgrades only after `userAuth` succeeds (session frozen into the socket at upgrade time); the `websocket` handler object lives in `src/websocket/` - `/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` - 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 - `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
@@ -27,7 +28,7 @@ This is **Cobble** (`cobble.moduletrace.org`): an open-source crew of small AI w
| Purpose | Command | | Purpose | Command |
| -------------------------------- | --------------------------------------------------------- | | -------------------------------- | --------------------------------------------------------- |
| Dev server | `bun run dev` (runs `bun src/server.ts`) | | Dev server | `bun run dev` (runs `bun src/server.ts`) |
| Production | `bun run start` (`MAIN_SERVER=1 NODE_ENV=production 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` | | Type-check only | `bun run typecheck` |
| Run tests | `bun run test` | | Run tests | `bun run test` |
| Regenerate favicon/brand/PWA assets | `bun run gen:favicon` (runs `src/scripts/generate-brand-assets.ts`) | | Regenerate favicon/brand/PWA assets | `bun run gen:favicon` (runs `src/scripts/generate-brand-assets.ts`) |
@@ -91,7 +92,7 @@ Inspect live data with `bun llm-code-assistant/query-db.ts "<sql>"` - 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. - **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. - **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. - **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, 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 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`).
- **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). - **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). - **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 "<name> 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. - **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 "<name> 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.
+2 -2
View File
@@ -9,7 +9,7 @@
"@modelcontextprotocol/sdk": "^1.32.1", "@modelcontextprotocol/sdk": "^1.32.1",
"@moduletrace/bun-sqlite": "^1.3.0", "@moduletrace/bun-sqlite": "^1.3.0",
"@moduletrace/bunext": "^1.3.3", "@moduletrace/bunext": "^1.3.3",
"@moduletrace/twui": "^0.1.2", "@moduletrace/twui": "^0.1.4",
"@tiptap/extension-bubble-menu": "3.32.0", "@tiptap/extension-bubble-menu": "3.32.0",
"@tiptap/extension-mention": "3.32.0", "@tiptap/extension-mention": "3.32.0",
"@tiptap/extension-placeholder": "3.32.0", "@tiptap/extension-placeholder": "3.32.0",
@@ -255,7 +255,7 @@
"@moduletrace/bunext": ["@moduletrace/[email protected]", "https://git.tben.me/api/packages/Moduletrace/npm/%40moduletrace%2Fbunext/-/1.3.3/bunext-1.3.3.tgz", { "dependencies": { "@tailwindcss/postcss": "^4.3.3", "@types/bun": "1.3.14", "@types/node": "^24.10.0", "@types/react": "^19.3.0", "@types/react-dom": "^19.3.0", "bun-plugin-tailwind": "^0.1.2", "chalk": "^5.6.2", "chokidar": "^5.0.0", "commander": "^14.0.2", "esbuild": "^0.28.2", "lightningcss-wasm": "^1.32.0", "lodash": "^4.18.1", "ora": "^9.0.0", "postcss": "^8.5.30", "react": "^19.3.0", "react-dom": "^19.3.0", "tailwindcss": "^4.3.3", "typescript": "^5.0.0", "uuid": "^14.0.2" }, "bin": { "bunext": "dist/commands/index.js" } }, "sha512-bbUtQaigsDPUQf6vtDOSCo8GYR7Gnw2ny1Fi3JtUnKsYsYQEcEadzmqntAgf7rLcKc12dadDUgFLxru+jiA92g=="], "@moduletrace/bunext": ["@moduletrace/[email protected]", "https://git.tben.me/api/packages/Moduletrace/npm/%40moduletrace%2Fbunext/-/1.3.3/bunext-1.3.3.tgz", { "dependencies": { "@tailwindcss/postcss": "^4.3.3", "@types/bun": "1.3.14", "@types/node": "^24.10.0", "@types/react": "^19.3.0", "@types/react-dom": "^19.3.0", "bun-plugin-tailwind": "^0.1.2", "chalk": "^5.6.2", "chokidar": "^5.0.0", "commander": "^14.0.2", "esbuild": "^0.28.2", "lightningcss-wasm": "^1.32.0", "lodash": "^4.18.1", "ora": "^9.0.0", "postcss": "^8.5.30", "react": "^19.3.0", "react-dom": "^19.3.0", "tailwindcss": "^4.3.3", "typescript": "^5.0.0", "uuid": "^14.0.2" }, "bin": { "bunext": "dist/commands/index.js" } }, "sha512-bbUtQaigsDPUQf6vtDOSCo8GYR7Gnw2ny1Fi3JtUnKsYsYQEcEadzmqntAgf7rLcKc12dadDUgFLxru+jiA92g=="],
"@moduletrace/twui": ["@moduletrace/[email protected].2", "https://git.tben.me/api/packages/Moduletrace/npm/%40moduletrace%2Ftwui/-/0.1.2/twui-0.1.2.tgz", { "dependencies": { "lodash": "^4.18.1", "tailwind-merge": "^3.7.0" }, "peerDependencies": { "ace-builds": "^1.40.0", "lucide-react": ">=0.577.0", "next-mdx-remote": "^6.0.0", "openai": ">=6.0.0", "react": ">=18", "react-dom": ">=18", "react-markdown": ">=9.0.0", "rehype-prism-plus": "^2.0.0", "remark-gfm": "^4.0.0", "tailwindcss": "^4" }, "optionalPeers": ["ace-builds", "next-mdx-remote", "openai", "react-markdown", "rehype-prism-plus", "remark-gfm"] }, "sha512-90j6jKyMWlgPAENTKW89Yj74tEA9cXXHQYKmxZKqL0T9gK/tYCSM+Rd/mo+1p3vJmNwTOHLViksL7+0v5Wk7Zg=="], "@moduletrace/twui": ["@moduletrace/[email protected].4", "https://git.tben.me/api/packages/Moduletrace/npm/%40moduletrace%2Ftwui/-/0.1.4/twui-0.1.4.tgz", { "dependencies": { "lodash": "^4.18.1", "tailwind-merge": "^3.7.0" }, "peerDependencies": { "ace-builds": "^1.40.0", "lucide-react": ">=0.577.0", "next-mdx-remote": "^6.0.0", "openai": ">=6.0.0", "react": ">=18", "react-dom": ">=18", "react-markdown": ">=9.0.0", "rehype-prism-plus": "^2.0.0", "remark-gfm": "^4.0.0", "tailwindcss": "^4" }, "optionalPeers": ["ace-builds", "next-mdx-remote", "openai", "react-markdown", "rehype-prism-plus", "remark-gfm"] }, "sha512-yPcr86nGd7CYYcEZYcm0IxMSkcP8U2vYFwqQyBr9UAHiG5JVxJauPDfxidCu740Abwbj2Oq+njjKoEP98SoM7A=="],
"@next/mdx": ["@next/[email protected]", "", { "dependencies": { "source-map": "^0.7.0" }, "peerDependencies": { "@mdx-js/loader": ">=0.15.0", "@mdx-js/react": ">=0.15.0" }, "optionalPeers": ["@mdx-js/loader", "@mdx-js/react"] }, "sha512-Vpfuf928ACvx4AOdDXWx/cj13Ak6xlvMn3QCkjvfQMmLnQWCesQTeY01CPDbIyZvjY+NOwP7bvbvJ2ghkj/3GA=="], "@next/mdx": ["@next/[email protected]", "", { "dependencies": { "source-map": "^0.7.0" }, "peerDependencies": { "@mdx-js/loader": ">=0.15.0", "@mdx-js/react": ">=0.15.0" }, "optionalPeers": ["@mdx-js/loader", "@mdx-js/react"] }, "sha512-Vpfuf928ACvx4AOdDXWx/cj13Ak6xlvMn3QCkjvfQMmLnQWCesQTeY01CPDbIyZvjY+NOwP7bvbvJ2ghkj/3GA=="],
+2 -2
View File
@@ -14,7 +14,7 @@
"db:backup": "bunx bun-sqlite backup", "db:backup": "bunx bun-sqlite backup",
"seed": "bun src/scripts/seed-super-admin.tsx", "seed": "bun src/scripts/seed-super-admin.tsx",
"seed:bots": "bun src/scripts/seed-bots.ts", "seed:bots": "bun src/scripts/seed-bots.ts",
"start": "MAIN_SERVER=1 NODE_ENV=production bun src/server.ts", "start": "NODE_ENV=production bunx bunext build && MAIN_SERVER=1 NODE_ENV=production bun src/server.ts",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"test": "bun test" "test": "bun test"
}, },
@@ -41,7 +41,7 @@
"@modelcontextprotocol/sdk": "^1.32.1", "@modelcontextprotocol/sdk": "^1.32.1",
"@moduletrace/bun-sqlite": "^1.3.0", "@moduletrace/bun-sqlite": "^1.3.0",
"@moduletrace/bunext": "^1.3.3", "@moduletrace/bunext": "^1.3.3",
"@moduletrace/twui": "^0.1.2", "@moduletrace/twui": "^0.1.4",
"@tiptap/extension-bubble-menu": "3.32.0", "@tiptap/extension-bubble-menu": "3.32.0",
"@tiptap/extension-mention": "3.32.0", "@tiptap/extension-mention": "3.32.0",
"@tiptap/extension-placeholder": "3.32.0", "@tiptap/extension-placeholder": "3.32.0",
+48 -1
View File
@@ -9,7 +9,7 @@
* Bump `CACHE_VERSION` when this file changes in a way that must drop the * Bump `CACHE_VERSION` when this file changes in a way that must drop the
* old caches. * old caches.
*/ */
const CACHE_VERSION = "cobble-v1"; const CACHE_VERSION = "cobble-v2";
const CACHE_NAME = `cobble-shell-${CACHE_VERSION}`; const CACHE_NAME = `cobble-shell-${CACHE_VERSION}`;
const OFFLINE_URL = "/offline.html"; const OFFLINE_URL = "/offline.html";
@@ -39,6 +39,16 @@ const ASSET_PATHS = ["/favicon.ico", "/favicon.png", "/apple-touch-icon.png"];
const BYPASS_PREFIXES = ["/api/", "/ws", "/media", "/computers/"]; const BYPASS_PREFIXES = ["/api/", "/ws", "/media", "/computers/"];
const BYPASS_PATHS = ["/sw.js", "/manifest.webmanifest", "/auth/logout"]; const BYPASS_PATHS = ["/sw.js", "/manifest.webmanifest", "/auth/logout"];
/**
* Page scripts and styles bunext names after their content: one at such
* a path never changes, so it is served from the cache without asking the
* network again (`src/utils/is-hashed-asset-path.ts` has the same
* pattern). Each deploy adds new names, so the oldest are dropped past
* `MAX_HASHED_ASSETS`.
*/
const HASHED_ASSET = /^\/\.bunext\/public\/pages\/(?:.*\/)?(?:chunk-)?[A-Z0-9]{8}\.(?:js|css)$/;
const MAX_HASHED_ASSETS = 400;
const DEFAULT_ICON = "/icons/icon-192.png"; const DEFAULT_ICON = "/icons/icon-192.png";
const DEFAULT_BADGE = "/icons/icon-192.png"; const DEFAULT_BADGE = "/icons/icon-192.png";
@@ -105,6 +115,11 @@ self.addEventListener("fetch", (event) => {
if (!isAssetRequest(url)) return; if (!isAssetRequest(url)) return;
if (HASHED_ASSET.test(url.pathname)) {
event.respondWith(cacheFirst(request));
return;
}
event.respondWith( event.respondWith(
caches.open(CACHE_NAME).then(async (cache) => { caches.open(CACHE_NAME).then(async (cache) => {
const cached = await cache.match(request); const cached = await cache.match(request);
@@ -123,6 +138,38 @@ self.addEventListener("fetch", (event) => {
); );
}); });
async function cacheFirst(request) {
const cache = await caches.open(CACHE_NAME);
const cached = await cache.match(request);
if (cached) return cached;
const response = await fetch(request);
if (response.ok && response.type === "basic") {
await cache.put(request, response.clone()).catch(() => undefined);
trimHashedAssets(cache).catch(() => undefined);
}
return response;
}
/**
* Cache keys come back in insertion order, so the first hashed ones are
* the oldest.
*/
async function trimHashedAssets(cache) {
const hashed = (await cache.keys()).filter((request) =>
HASHED_ASSET.test(new URL(request.url).pathname),
);
await Promise.all(
hashed
.slice(0, Math.max(0, hashed.length - MAX_HASHED_ASSETS))
.map((request) => cache.delete(request)),
);
}
/** /**
* The page asks for a new version from its update notice. Nothing skips * The page asks for a new version from its update notice. Nothing skips
* the wait until the user says so. * the wait until the user says so.
+4 -11
View File
@@ -2,21 +2,20 @@ import Section from "@moduletrace/twui/layout/Section";
import Container from "@moduletrace/twui/layout/Container"; import Container from "@moduletrace/twui/layout/Container";
import H2 from "@moduletrace/twui/layout/H2"; import H2 from "@moduletrace/twui/layout/H2";
import Stack from "@moduletrace/twui/layout/Stack"; import Stack from "@moduletrace/twui/layout/Stack";
import type { TWUILucideIconName } from "@moduletrace/twui/elements/lucide-icon"; import type { LucideIcon } from "lucide-react";
import LucideIcon from "@moduletrace/twui/elements/lucide-icon";
import Span from "@moduletrace/twui/layout/Span"; import Span from "@moduletrace/twui/layout/Span";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
type Props = { type Props = {
tagline: string; tagline: string;
icon_name?: TWUILucideIconName; icon?: LucideIcon;
sub_title?: string; sub_title?: string;
post_component?: ReactNode; post_component?: ReactNode;
}; };
export default function TaglineSection({ export default function TaglineSection({
tagline, tagline,
icon_name, icon: Icon,
sub_title, sub_title,
post_component, post_component,
}: Props) { }: Props) {
@@ -27,13 +26,7 @@ export default function TaglineSection({
<Section className="bg-secondary relative"> <Section className="bg-secondary relative">
<Container className="xl:py-10"> <Container className="xl:py-10">
<Stack className="w-full items-center gap-6"> <Stack className="w-full items-center gap-6">
{icon_name && ( {Icon && <Icon size={50} className="text-accent" />}
<LucideIcon
name={icon_name}
size={50}
className="text-accent"
/>
)}
<H2 className="text-center max-w-4xl xl:text-6xl! text-white"> <H2 className="text-center max-w-4xl xl:text-6xl! text-white">
{tagline} {tagline}
</H2> </H2>
+23
View File
@@ -11,6 +11,13 @@ export const AppData = {
* bot and workspace. * bot and workspace.
*/ */
ComposerDraftStorageKey: `${SiteData["SiteSlug"]}_composer_draft`, 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.
*/
HarnessOverviewStorageKey: `${SiteData["SiteSlug"]}_harness_overview`,
HarnessOverviewCacheMs: 10 * 60 * 1000,
ServerHealthMessage: "Server Running Well", ServerHealthMessage: "Server Running Well",
/** /**
* Where a device downloads the local CA that signed the app's * Where a device downloads the local CA that signed the app's
@@ -351,4 +358,20 @@ export const AppData = {
* is offline. * is offline.
*/ */
PushTtlSeconds: 60 * 60, PushTtlSeconds: 60 * 60,
/**
* Responses smaller than this go out uncompressed: the encoding
* costs more than it saves.
*/
CompressMinBytes: 1024,
/**
* 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.
*/
CompressBrotliQuality: 9,
/**
* How many compressed page scripts and styles stay in memory. A
* rebuild names its files anew, so the oldest go first.
*/
CompressCacheMaxFiles: 600,
} as const; } as const;
@@ -1,11 +1,9 @@
import type { PagePropsType } from "@/src/types"; import type { BotTextModel, PagePropsType } from "@/src/types";
import grabBots from "../bots/grab-bots"; import grabBots from "../bots/grab-bots";
import grabActiveBotTask from "../bots/grab-active-bot-task"; import grabActiveBotTask from "../bots/grab-active-bot-task";
import grabBotMessages from "../bots/grab-bot-messages"; import grabBotMessages from "../bots/grab-bot-messages";
import grabBotExtensionsOverview from "../extensions/grab-bot-extensions-overview"; import grabBotExtensionsOverview from "../extensions/grab-bot-extensions-overview";
import grabBotComputers from "../computers/grab-bot-computers"; 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 resolveHarnessCandidates from "@/src/harness/resolve/resolve-harness-candidates";
import grabUserWorkspaces from "../workspaces/grab-user-workspaces"; import grabUserWorkspaces from "../workspaces/grab-user-workspaces";
import ensureBotSession from "./ensure-bot-session"; import ensureBotSession from "./ensure-bot-session";
@@ -25,9 +23,11 @@ type Params = {
* The page props of a conversation (`/admin/bots/<id>` or * The page props of a conversation (`/admin/bots/<id>` or
* `/admin/workspaces/<id>/bots/<id>`): the rail's crew (the workspace's * `/admin/workspaces/<id>/bots/<id>`): the rail's crew (the workspace's
* in a workspace) and workspaces, the bot, its active session (marked * in a workspace) and workspaces, the bot, its active session (marked
* read) and its messages, its models, computers, tools and MCP servers * read) and its messages, the text model it runs on, its computers,
* and active task there, and whether its mic and read-aloud have a model * tools and MCP servers and active task there, and whether its mic and
* to run on. * 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. * Null when the user can't chat with the bot there.
*/ */
export default async function grabConversationProps({ export default async function grabConversationProps({
@@ -60,21 +60,9 @@ export default async function grabConversationProps({
bot.unread = 0; bot.unread = 0;
/** const [bot_messages, bot_text_model, bot_computers, bot_task, bot_extensions, transcription, speech] = await Promise.all([
* 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([
grabBotMessages({ session_id: session.id!, user_id }), grabBotMessages({ session_id: session.id!, user_id }),
(bot.kind == "coordinator" && !scope grabTextModel({ bot_id: bot.id, workspace_id: scope }),
? grabHarnessOverview().then(({ harnesses }) => ({ harnesses }))
: grabBotHarnessOverview({
bot_id: bot.kind == "coordinator" ? undefined : bot.id,
workspace_id: scope || undefined,
})
).catch(() => null),
grabBotComputers({ bot_id: bot.id, workspace_id: scope }), grabBotComputers({ bot_id: bot.id, workspace_id: scope }),
grabActiveBotTask({ bot_id: bot.id, session_id: session.id }), grabActiveBotTask({ bot_id: bot.id, session_id: session.id }),
grabBotExtensionsOverview({ user_id, bot_id: bot.id, workspace_id: scope }), grabBotExtensionsOverview({ user_id, bot_id: bot.id, workspace_id: scope }),
@@ -89,7 +77,7 @@ export default async function grabConversationProps({
workspace: conversation.workspace, workspace: conversation.workspace,
bot_session: session, bot_session: session,
bot_messages, bot_messages,
bot_harness_overview, bot_text_model,
bot_computers, bot_computers,
bot_task, bot_task,
bot_extensions, bot_extensions,
@@ -105,3 +93,19 @@ async function hasHarnessModel(params: Parameters<typeof resolveHarnessCandidate
.then((candidates) => candidates.length > 0) .then((candidates) => candidates.length > 0)
.catch(() => false); .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,
});
}
+115
View File
@@ -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;
}
@@ -0,0 +1,18 @@
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";
/**
* What a conversation's models modal edits: per harness, the setting, the
* models it can pick and what it runs on now.
*/
export default async function fetchHarnessOverview({ bot_id, workspace_id }: BotConversationRequest) {
return await fetchApi<
BotConversationRequest,
APIResponseObject<BotHarnessOverviewPayload>
>(`/api/admin/bots/harness-overview`, {
method: "POST",
body: { bot_id, workspace_id },
});
}
@@ -0,0 +1,73 @@
import { useEffect, useState } from "react";
import { AppData } from "@/src/data/app-data";
import type { HarnessOverview } from "@/src/types";
import fetchHarnessOverview from "../(functions)/fetch-harness-overview";
import {
readHarnessOverviewCache,
writeHarnessOverviewCache,
} from "../(utils)/harness-overview-cache";
type Params = {
bot_id: number;
workspace_id?: number | null;
/**
* Loads once this turns true (the modal is about to open), not with
* the page.
*/
enabled: boolean;
};
/**
* 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.
*/
export default function useHarnessOverview({ bot_id, workspace_id, enabled }: Params) {
const [harnesses, setState] = useState<HarnessOverview[]>();
const [error, setError] = useState<string>();
/**
* Counts fetched updates, so a form showing the cached data remounts
* with the fresh.
*/
const [version, setVersion] = useState(0);
useEffect(() => {
if (!enabled) return;
let cancelled = false;
const cached = readHarnessOverviewCache({ bot_id, workspace_id });
if (cached) setState(cached.harnesses);
if (cached && Date.now() - cached.at < AppData.HarnessOverviewCacheMs) return;
setError(undefined);
fetchHarnessOverview({ bot_id, workspace_id })
.then((res) => {
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) {
setError(res.msg || "Couldn't load the models.");
}
})
.catch(() => {
if (!cancelled && !cached) setError("Couldn't load the models.");
});
return () => {
cancelled = true;
};
}, [enabled, bot_id, workspace_id]);
const setHarnesses = (fresh: HarnessOverview[]) => {
setState(fresh);
writeHarnessOverviewCache({ bot_id, workspace_id, harnesses: fresh });
};
return { harnesses, error, version, setHarnesses };
}
@@ -1,4 +1,4 @@
import { useContext } from "react"; import { memo, useContext } from "react";
import Markdown from "react-markdown"; import Markdown from "react-markdown";
import remarkGfm from "remark-gfm"; import remarkGfm from "remark-gfm";
import Row from "@moduletrace/twui/layout/Row"; import Row from "@moduletrace/twui/layout/Row";
@@ -31,8 +31,11 @@ type Props = {
* switch the bot to them. A quoted reply sits above it, its reactions * switch the bot to them. A quoted reply sits above it, its reactions
* below, and a row under it replies, reacts, reads it aloud and shows * below, and a row under it replies, reacts, reads it aloud and shows
* why it needs you. Its text can be selected and quoted. * why it needs you. Its text can be selected and quoted.
*
* Memoized: a message only renders (and parses its markdown) again when
* it changes, not on every live step of the thread.
*/ */
export default function BotMessageLine({ message, bot_name, at }: Props) { function BotMessageLine({ message, bot_name, at }: Props) {
const { onReact } = useContext(BotMessageActionsContext); const { onReact } = useContext(BotMessageActionsContext);
const can_speak = Boolean(message.id && message.text?.trim()); const can_speak = Boolean(message.id && message.text?.trim());
@@ -73,3 +76,5 @@ export default function BotMessageLine({ message, bot_name, at }: Props) {
</Stack> </Stack>
); );
} }
export default memo(BotMessageLine);
@@ -3,6 +3,7 @@ import Modal from "@moduletrace/twui/elements/Modal";
import H2 from "@moduletrace/twui/layout/H2"; import H2 from "@moduletrace/twui/layout/H2";
import Span from "@moduletrace/twui/layout/Span"; import Span from "@moduletrace/twui/layout/Span";
import Stack from "@moduletrace/twui/layout/Stack"; import Stack from "@moduletrace/twui/layout/Stack";
import Loading from "@moduletrace/twui/elements/Loading";
import { CpuAi } from "lucide-react"; import { CpuAi } from "lucide-react";
import { AppContext } from "@/src/pages/__root"; import { AppContext } from "@/src/pages/__root";
import type { BUN_SQLITE_COBBLE_WORKSPACES } from "@/db/types/db"; import type { BUN_SQLITE_COBBLE_WORKSPACES } from "@/db/types/db";
@@ -11,6 +12,7 @@ import canManageCrew from "@/src/utils/rbac/can-manage-crew";
import isPassThroughUser from "@/src/utils/rbac/is-pass-through-user"; import isPassThroughUser from "@/src/utils/rbac/is-pass-through-user";
import BotHarnessForm from "@/src/pages/admin/settings/(partials)/bot-harness-form"; import BotHarnessForm from "@/src/pages/admin/settings/(partials)/bot-harness-form";
import HeaderIconButton from "@/src/components/general/header-icon-button"; import HeaderIconButton from "@/src/components/general/header-icon-button";
import useHarnessOverview from "../(hooks)/use-harness-overview";
type Props = { type Props = {
bot: BotWithLastMessage; bot: BotWithLastMessage;
@@ -18,7 +20,6 @@ type Props = {
* The workspace the conversation is in: the modal edits its models. * The workspace the conversation is in: the modal edits its models.
*/ */
workspace?: BUN_SQLITE_COBBLE_WORKSPACES | null; workspace?: BUN_SQLITE_COBBLE_WORKSPACES | null;
harnesses?: HarnessOverview[] | null;
onSaved: (harnesses: HarnessOverview[]) => void; onSaved: (harnesses: HarnessOverview[]) => void;
}; };
@@ -28,12 +29,18 @@ type Props = {
* the coordinator that form is the defaults, for super admins only; a * the coordinator that form is the defaults, for super admins only; a
* worker's (or a workspace's) models are for super admins and admins. * worker's (or a workspace's) models are for super admins and admins.
* Everyone else gets the icon disabled, its title saying why. * Everyone else gets the icon disabled, its title saying why.
*
* The models load when the pointer or focus reaches the icon, or it
* opens (`useHarnessOverview`), shown at once from the last time when
* this device has them.
*/ */
export default function BotModelsModal({ bot, workspace, harnesses, onSaved }: Props) { export default function BotModelsModal({ bot, workspace, onSaved }: Props) {
const { pageProps } = useContext(AppContext); const { pageProps } = useContext(AppContext);
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
/**
if (!harnesses?.length || !bot.id) return null; * The pointer or focus reached the icon: start loading before the click.
*/
const [warm, setWarm] = useState(false);
const in_workspace = Boolean(workspace?.id); const in_workspace = Boolean(workspace?.id);
const is_coordinator = bot.kind == "coordinator" && !in_workspace; const is_coordinator = bot.kind == "coordinator" && !in_workspace;
@@ -42,6 +49,14 @@ export default function BotModelsModal({ bot, workspace, harnesses, onSaved }: P
: canManageCrew({ user_types: pageProps?.user_types }); : canManageCrew({ user_types: pageProps?.user_types });
const name = bot.name || "This bot"; const name = bot.name || "This bot";
const { harnesses, error, version, setHarnesses } = useHarnessOverview({
bot_id: Number(bot.id),
workspace_id: workspace?.id || null,
enabled: (open || warm) && can_edit,
});
if (!bot.id) return null;
const target = ( const target = (
<HeaderIconButton <HeaderIconButton
icon={CpuAi} icon={CpuAi}
@@ -65,7 +80,11 @@ export default function BotModelsModal({ bot, workspace, harnesses, onSaved }: P
return ( return (
<Modal <Modal
target={target} target={target}
targetWrapperProps={{ className: "shrink-0" }} targetWrapperProps={{
className: "shrink-0",
onPointerEnter: () => setWarm(true),
onFocus: () => setWarm(true),
}}
open={open} open={open}
setOpen={setOpen} setOpen={setOpen}
className="max-w-3xl bg-paper px-6 pt-6 pb-0" className="max-w-3xl bg-paper px-6 pt-6 pb-0"
@@ -86,12 +105,24 @@ export default function BotModelsModal({ bot, workspace, harnesses, onSaved }: P
: `${name} uses the default models unless you pick its own. The text model chats, plans and drives computers; the others read media, transcribe, speak and make images for it.`} : `${name} uses the default models unless you pick its own. The text model chats, plans and drives computers; the others read media, transcribe, speak and make images for it.`}
</Span> </Span>
</Stack> </Stack>
{harnesses?.length ? (
<BotHarnessForm <BotHarnessForm
key={version}
bot={bot} bot={bot}
workspace_id={workspace?.id || null} workspace_id={workspace?.id || null}
harnesses={harnesses} harnesses={harnesses}
onSaved={onSaved} onSaved={(fresh) => {
setHarnesses(fresh);
onSaved(fresh);
}}
/> />
) : error ? (
<Span className="py-6 text-[13px] leading-[18px] text-ink-secondary">{error}</Span>
) : (
<Stack className="w-full py-10 items-center">
<Loading />
</Stack>
)}
</Modal> </Modal>
); );
} }
@@ -0,0 +1,12 @@
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 (
<Span className="grow min-w-0 self-center min-h-5 text-sm leading-5 text-ink-secondary truncate">
{placeholder}
</Span>
);
}
@@ -0,0 +1,43 @@
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<typeof useComposerEditor>;
type Props = Parameters<typeof useComposerEditor>[0] & {
/**
* Gets the editor's commands (`setMarkdown`, `clear`, `insertText`,
* `insertQuote`) for the composer around it.
*/
api_ref: RefObject<ComposerInputApi | null>;
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 <ComposerEditor editor={input.editor} invalid={invalid} title={title} />;
}
@@ -1,4 +1,5 @@
import { useContext, useEffect, useImperativeHandle, useRef, useState, type Ref } from "react"; import { lazy, Suspense, useContext, useEffect, useImperativeHandle, useRef, useState, type Ref } from "react";
import type { Editor } from "@tiptap/react";
import Row from "@moduletrace/twui/layout/Row"; import Row from "@moduletrace/twui/layout/Row";
import Stack from "@moduletrace/twui/layout/Stack"; import Stack from "@moduletrace/twui/layout/Stack";
import P from "@moduletrace/twui/layout/P"; import P from "@moduletrace/twui/layout/P";
@@ -14,18 +15,25 @@ import sendBotMessage from "../(functions)/send-bot-message";
import transcribeVoice from "../(functions)/transcribe-voice"; import transcribeVoice from "../(functions)/transcribe-voice";
import useComposerAttachments from "../(hooks)/use-composer-attachments"; import useComposerAttachments from "../(hooks)/use-composer-attachments";
import useComposerDraft from "../(hooks)/use-composer-draft"; import useComposerDraft from "../(hooks)/use-composer-draft";
import useComposerEditor from "../(hooks)/use-composer-editor"; import type { ComposerMentionMenu } from "../(hooks)/use-composer-editor";
import useVoiceRecorder from "../(hooks)/use-voice-recorder"; import useVoiceRecorder from "../(hooks)/use-voice-recorder";
import useOnlineStatus from "@/src/hooks/use-online-status"; import useOnlineStatus from "@/src/hooks/use-online-status";
import ComposerAttachButton from "./composer-attach-button"; import ComposerAttachButton from "./composer-attach-button";
import ComposerAttachments from "./composer-attachments"; import ComposerAttachments from "./composer-attachments";
import ComposerEditor from "./composer-editor"; import ComposerInputFallback from "./composer-input-fallback";
import type { ComposerInputApi } from "./composer-input";
import ComposerMentionList from "./composer-mention-list"; import ComposerMentionList from "./composer-mention-list";
import ComposerReplyPreview from "./composer-reply-preview"; import ComposerReplyPreview from "./composer-reply-preview";
import ComposerSendButton from "./composer-send-button"; import ComposerSendButton from "./composer-send-button";
import ComposerTools from "./composer-tools"; import ComposerTools from "./composer-tools";
import ComposerVoiceButton from "./composer-voice-button"; 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. * What the conversation can do to the composer from outside it.
*/ */
@@ -60,7 +68,7 @@ type Props = {
}; };
/** /**
* A Tiptap editor (`useComposerEditor`) writing markdown: Enter sends, * A Tiptap editor (`useComposerEditor`, loaded lazily by `ComposerInput`) writing markdown: Enter sends,
* Shift+Enter adds a line, a selection can be formatted, `@` mentions a * Shift+Enter adds a line, a selection can be formatted, `@` mentions a
* bot and `#` a workspace. Grows to eight lines, then scrolls. * 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 * Its top rule lines up with the rail's user-row rule; picked files sit
@@ -90,17 +98,16 @@ export default function Composer({
const workspaces = useContext(CrewWorkspacesContext); const workspaces = useContext(CrewWorkspacesContext);
const send_ref = useRef<() => void>(() => {}); const send_ref = useRef<() => void>(() => {});
const input = useComposerEditor({ const input = useRef<ComposerInputApi | null>(null);
placeholder: `Message ${bot.name}`, const [editor, setEditor] = useState<Editor | null>(null);
bots, const [menu, setMenu] = useState<ComposerMentionMenu | null>(null);
workspaces, const placeholder = `Message ${bot.name}`;
onChange: ({ markdown }) => setText(markdown),
onSubmit: () => send_ref.current(),
onFiles: files.add,
});
const editor = input.editor;
useImperativeHandle(handle_ref, () => ({ insertQuote: input.insertQuote }), [input.insertQuote]); useImperativeHandle(
handle_ref,
() => ({ insertQuote: ({ text }) => input.current?.insertQuote({ text }) }),
[],
);
/** /**
* The saved draft goes into the editor once per conversation, after * The saved draft goes into the editor once per conversation, after
@@ -115,7 +122,7 @@ export default function Composer({
/** Out of React's render, since mention chips mount as it loads. */ /** Out of React's render, since mention chips mount as it loads. */
queueMicrotask(() => { queueMicrotask(() => {
input.setMarkdown({ markdown: text }); input.current?.setMarkdown({ markdown: text });
editor.commands.focus("end"); editor.commands.focus("end");
}); });
}, [editor, draft.loaded, draft.key]); }, [editor, draft.loaded, draft.key]);
@@ -138,7 +145,7 @@ export default function Composer({
return; return;
} }
input.insertText({ text: res.singleRes.text }); input.current?.insertText({ text: res.singleRes.text });
}, },
}); });
@@ -171,7 +178,7 @@ export default function Composer({
} }
setText(""); setText("");
input.clear(); input.current?.clear();
files.clear(); files.clear();
onSent({ message: res.singleRes }); onSent({ message: res.singleRes });
} }
@@ -204,7 +211,7 @@ export default function Composer({
onCancel={onCancelReply} onCancel={onCancelReply}
/> />
) : null} ) : null}
{input.menu ? <ComposerMentionList menu={input.menu} /> : null} {menu ? <ComposerMentionList menu={menu} /> : null}
<Row className={`w-full max-w-container mx-auto min-h-[88px] ${reply_to ? "pt-3 pb-7" : "py-7"} gap-3! md:gap-4! flex-nowrap items-end px-3 md:px-6 cursor-text`} <Row className={`w-full max-w-container mx-auto min-h-[88px] ${reply_to ? "pt-3 pb-7" : "py-7"} gap-3! md:gap-4! flex-nowrap items-end px-3 md:px-6 cursor-text`}
onMouseDown={(e) => { onMouseDown={(e) => {
const target = e.target as HTMLElement; const target = e.target as HTMLElement;
@@ -219,7 +226,21 @@ export default function Composer({
disabled={files.full} disabled={files.full}
onFiles={files.add} onFiles={files.add}
/> />
<ComposerEditor editor={editor} invalid={Boolean(error)} title={error} /> <Suspense fallback={<ComposerInputFallback placeholder={placeholder} />}>
<ComposerInput
api_ref={input}
onEditor={setEditor}
onMenu={setMenu}
placeholder={placeholder}
bots={bots}
workspaces={workspaces}
onChange={({ markdown }) => setText(markdown)}
onSubmit={() => send_ref.current()}
onFiles={files.add}
invalid={Boolean(error)}
title={error}
/>
</Suspense>
{recorder.supported ? ( {recorder.supported ? (
<ComposerVoiceButton <ComposerVoiceButton
state={recorder.state} state={recorder.state}
@@ -4,7 +4,7 @@ import Stack from "@moduletrace/twui/layout/Stack";
import H1 from "@moduletrace/twui/layout/H1"; import H1 from "@moduletrace/twui/layout/H1";
import { CpuAi, Layers, Monitor, Settings } from "lucide-react"; import { CpuAi, Layers, Monitor, Settings } from "lucide-react";
import type { BUN_SQLITE_COBBLE_WORKSPACES } from "@/db/types/db"; import type { BUN_SQLITE_COBBLE_WORKSPACES } from "@/db/types/db";
import type { BotWithLastMessage, HarnessOverview } from "@/src/types"; import type { BotWithLastMessage } from "@/src/types";
import { AppContext } from "@/src/pages/__root"; import { AppContext } from "@/src/pages/__root";
import canManageCrew from "@/src/utils/rbac/can-manage-crew"; import canManageCrew from "@/src/utils/rbac/can-manage-crew";
import isPassThroughUser from "@/src/utils/rbac/is-pass-through-user"; import isPassThroughUser from "@/src/utils/rbac/is-pass-through-user";
@@ -26,10 +26,6 @@ type Props = {
*/ */
workspace?: BUN_SQLITE_COBBLE_WORKSPACES | null; workspace?: BUN_SQLITE_COBBLE_WORKSPACES | null;
has_other_unread?: boolean; has_other_unread?: boolean;
/**
* The bot's models (the defaults for the coordinator).
*/
harnesses?: HarnessOverview[] | null;
}; };
/** /**
@@ -38,12 +34,10 @@ type Props = {
* that open the computers and models modals. Kept here so a save or a * that open the computers and models modals. Kept here so a save or a
* switch shows under the name at once. * switch shows under the name at once.
*/ */
export default function ConversationHeader({ bot, workspace, has_other_unread, harnesses: initial }: Props) { export default function ConversationHeader({ bot, workspace, has_other_unread }: Props) {
const [harnesses, setHarnesses] = useState(initial);
const { computers } = useContext(BotComputersContext); const { computers } = useContext(BotComputersContext);
const { pageProps } = useContext(AppContext); const { pageProps } = useContext(AppContext);
const [model, setModel] = useState(pageProps?.bot_text_model);
const model = harnesses?.find((h) => h.harness == "text")?.current;
const computer = computers[0]; const computer = computers[0];
const can_open_settings = const can_open_settings =
bot.kind == "coordinator" bot.kind == "coordinator"
@@ -93,8 +87,11 @@ export default function ConversationHeader({ bot, workspace, has_other_unread, h
key={bot.id} key={bot.id}
bot={bot} bot={bot}
workspace={workspace} workspace={workspace}
harnesses={harnesses} onSaved={(harnesses) => {
onSaved={setHarnesses} const current = harnesses.find((h) => h.harness == "text")?.current;
setModel(current ? { name: current.name, provider_name: current.provider_name } : null);
}}
/> />
</span> </span>
{can_open_settings ? ( {can_open_settings ? (
@@ -1,4 +1,4 @@
import { useRef, type RefObject } from "react"; import { useMemo, useRef, type RefObject } from "react";
import Stack from "@moduletrace/twui/layout/Stack"; import Stack from "@moduletrace/twui/layout/Stack";
import type { import type {
BotMessageWithAttachments, BotMessageWithAttachments,
@@ -38,7 +38,7 @@ type Props = {
* last one. Text selected in a message can be quoted in the composer. * last one. Text selected in a message can be quoted in the composer.
*/ */
export default function MessageThread({ bot, messages, progress, reply_progress, has_task, onQuote }: Props) { export default function MessageThread({ bot, messages, progress, reply_progress, has_task, onQuote }: Props) {
const groups = groupMessages({ messages }); const groups = useMemo(() => groupMessages({ messages }), [messages]);
const ref = useRef<HTMLDivElement>(null); const ref = useRef<HTMLDivElement>(null);
const { selection, clear } = useMessageSelection({ ref }); const { selection, clear } = useMessageSelection({ ref });
@@ -1,3 +1,4 @@
import { memo } from "react";
import Stack from "@moduletrace/twui/layout/Stack"; import Stack from "@moduletrace/twui/layout/Stack";
import type { BotMessageWithAttachments } from "@/src/types"; import type { BotMessageWithAttachments } from "@/src/types";
import MessageAttachments from "./message-attachments"; import MessageAttachments from "./message-attachments";
@@ -13,9 +14,10 @@ type Props = {
/** /**
* One user message: the quoted message it answers, its files, then its * One user message: the quoted message it answers, its files, then its
* text (a file-only message has no bubble) and the bot's reactions to it. * text (a file-only message has no bubble) and the bot's reactions to it.
* The user doesn't reply to or react to their own messages. * The user doesn't reply to or react to their own messages. Memoized,
* like `BotMessageLine`.
*/ */
export default function UserMessage({ message, bot_name }: Props) { function UserMessage({ message, bot_name }: Props) {
return ( return (
<Stack className="w-full min-w-0 max-w-full items-end gap-1.5!"> <Stack className="w-full min-w-0 max-w-full items-end gap-1.5!">
{message.reply_to ? ( {message.reply_to ? (
@@ -36,3 +38,5 @@ export default function UserMessage({ message, bot_name }: Props) {
</Stack> </Stack>
); );
} }
export default memo(UserMessage);
@@ -1,4 +1,4 @@
import { useContext, useEffect, useRef, useState } from "react"; import { useContext, useEffect, useMemo, useRef, useState } from "react";
import Stack from "@moduletrace/twui/layout/Stack"; import Stack from "@moduletrace/twui/layout/Stack";
import type { BotMessageReactionEmoji } from "@/src/dict/bot-dict"; import type { BotMessageReactionEmoji } from "@/src/dict/bot-dict";
import type { import type {
@@ -162,6 +162,18 @@ export default function Conversation() {
if (res.success && res.singleRes) applyReactions(res.singleRes); if (res.success && res.singleRes) applyReactions(res.singleRes);
}; };
/**
* Kept the same across renders, so a live step or a face change
* doesn't re-render every message (they read it from context).
*/
const message_actions = useMemo(
() => ({
onReply: ({ message }: { message: BotMessageWithAttachments }) => setReplyTo(message),
onReact: reactToMessage,
}),
[],
);
if (!bot?.id || !live_bot) return null; if (!bot?.id || !live_bot) return null;
return ( return (
@@ -169,18 +181,12 @@ export default function Conversation() {
<CrewWorkspacesContext.Provider value={workspaces}> <CrewWorkspacesContext.Provider value={workspaces}>
<BotComputersContext.Provider value={bot_computers}> <BotComputersContext.Provider value={bot_computers}>
<SpeakMessageContext.Provider value={speak}> <SpeakMessageContext.Provider value={speak}>
<BotMessageActionsContext.Provider <BotMessageActionsContext.Provider value={message_actions}>
value={{
onReply: ({ message }) => setReplyTo(message),
onReact: reactToMessage,
}}
>
<Stack className="grow min-w-0 h-full gap-0! items-stretch"> <Stack className="grow min-w-0 h-full gap-0! items-stretch">
<ConversationHeader <ConversationHeader
bot={live_bot} bot={live_bot}
workspace={workspace} workspace={workspace}
has_other_unread={has_other_unread} has_other_unread={has_other_unread}
harnesses={pageProps?.bot_harness_overview?.harnesses}
/> />
<MessageThread <MessageThread
bot={live_bot} bot={live_bot}
@@ -0,0 +1,50 @@
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 {}
}
+2 -2
View File
@@ -6,7 +6,7 @@ import Row from "@moduletrace/twui/layout/Row";
import Stack from "@moduletrace/twui/layout/Stack"; import Stack from "@moduletrace/twui/layout/Stack";
import Span from "@moduletrace/twui/layout/Span"; import Span from "@moduletrace/twui/layout/Span";
import Checkbox from "@moduletrace/twui/form/Checkbox"; import Checkbox from "@moduletrace/twui/form/Checkbox";
import LucideIcon from "@moduletrace/twui/elements/lucide-icon"; import { Trash2 } from "lucide-react";
import dayjs from "dayjs"; import dayjs from "dayjs";
import type { TWUIButtonColorType } from "@moduletrace/twui/layout/Button"; import type { TWUIButtonColorType } from "@moduletrace/twui/layout/Button";
import { import {
@@ -79,7 +79,7 @@ export default function LogCard({
</Stack> </Stack>
</Row> </Row>
<DeleteLogsButton ids={[log_id]} afterDelete={afterDelete}> <DeleteLogsButton ids={[log_id]} afterDelete={afterDelete}>
<LucideIcon name="Trash2" size={16} /> <Trash2 size={16} />
</DeleteLogsButton> </DeleteLogsButton>
</Row> </Row>
{log.plain_text ? ( {log.plain_text ? (
@@ -2,7 +2,7 @@ import type { BUN_SQLITE_COBBLE_LOGS_JOIN } from "@/src/types/sql-joins";
import Modal from "@moduletrace/twui/elements/Modal"; import Modal from "@moduletrace/twui/elements/Modal";
import Paper from "@moduletrace/twui/elements/Paper"; import Paper from "@moduletrace/twui/elements/Paper";
import Tag from "@moduletrace/twui/elements/Tag"; import Tag from "@moduletrace/twui/elements/Tag";
import LucideIcon from "@moduletrace/twui/elements/lucide-icon"; import { Trash2 } from "lucide-react";
import H3 from "@moduletrace/twui/layout/H3"; import H3 from "@moduletrace/twui/layout/H3";
import Link from "@moduletrace/twui/layout/Link"; import Link from "@moduletrace/twui/layout/Link";
import Row from "@moduletrace/twui/layout/Row"; import Row from "@moduletrace/twui/layout/Row";
@@ -115,7 +115,7 @@ export default function LogDetailsModal({
size="small" size="small"
> >
<Row className="gap-2! flex-nowrap"> <Row className="gap-2! flex-nowrap">
<LucideIcon name="Trash2" size={15} /> <Trash2 size={15} />
<Span>Delete log</Span> <Span>Delete log</Span>
</Row> </Row>
</DeleteLogsButton> </DeleteLogsButton>
@@ -1,5 +1,5 @@
import Tag from "@moduletrace/twui/elements/Tag"; import Tag from "@moduletrace/twui/elements/Tag";
import LucideIcon from "@moduletrace/twui/elements/lucide-icon"; import { Trash2 } from "lucide-react";
import Checkbox from "@moduletrace/twui/form/Checkbox"; import Checkbox from "@moduletrace/twui/form/Checkbox";
import Span from "@moduletrace/twui/layout/Span"; import Span from "@moduletrace/twui/layout/Span";
import type { BUN_SQLITE_COBBLE_LOGS_JOIN } from "@/src/types/sql-joins"; import type { BUN_SQLITE_COBBLE_LOGS_JOIN } from "@/src/types/sql-joins";
@@ -100,7 +100,7 @@ export default function LogTableRow({
afterDelete={afterDelete} afterDelete={afterDelete}
size="small" size="small"
> >
<LucideIcon name="Trash2" size={15} /> <Trash2 size={15} />
</DeleteLogsButton> </DeleteLogsButton>
</td> </td>
</tr> </tr>
@@ -1,5 +1,5 @@
import Paper from "@moduletrace/twui/elements/Paper"; import Paper from "@moduletrace/twui/elements/Paper";
import LucideIcon from "@moduletrace/twui/elements/lucide-icon"; import { Trash2 } from "lucide-react";
import Row from "@moduletrace/twui/layout/Row"; import Row from "@moduletrace/twui/layout/Row";
import Span from "@moduletrace/twui/layout/Span"; import Span from "@moduletrace/twui/layout/Span";
import Stack from "@moduletrace/twui/layout/Stack"; import Stack from "@moduletrace/twui/layout/Stack";
@@ -42,7 +42,7 @@ export default function LogsToolbar({ init }: Props) {
size="small" size="small"
> >
<Row className="gap-2! flex-nowrap"> <Row className="gap-2! flex-nowrap">
<LucideIcon name="Trash2" size={15} /> <Trash2 size={15} />
<Span>Delete selected ({selected_ids.length})</Span> <Span>Delete selected ({selected_ids.length})</Span>
</Row> </Row>
</DeleteLogsButton> </DeleteLogsButton>
@@ -1,3 +1,4 @@
import { DatabaseBackup } from "lucide-react";
import Button from "@moduletrace/twui/layout/Button"; import Button from "@moduletrace/twui/layout/Button";
import Tag from "@moduletrace/twui/elements/Tag"; import Tag from "@moduletrace/twui/elements/Tag";
import Row from "@moduletrace/twui/layout/Row"; import Row from "@moduletrace/twui/layout/Row";
@@ -30,7 +31,7 @@ export default function CreateBackupButton() {
{status?.error && <Tag color="error">{status.msg}</Tag>} {status?.error && <Tag color="error">{status.msg}</Tag>}
<Button <Button
title="Create Backup" title="Create Backup"
beforeIcon="DatabaseBackup" beforeIcon={<DatabaseBackup />}
loading={loading} loading={loading}
onClick={() => { onClick={() => {
create_backup(); create_backup();
@@ -1,3 +1,4 @@
import { Upload } from "lucide-react";
import FileUpload from "@moduletrace/twui/form/FileUpload"; import FileUpload from "@moduletrace/twui/form/FileUpload";
import Button from "@moduletrace/twui/layout/Button"; import Button from "@moduletrace/twui/layout/Button";
import Tag from "@moduletrace/twui/elements/Tag"; import Tag from "@moduletrace/twui/elements/Tag";
@@ -47,7 +48,7 @@ export default function UploadRestoreForm() {
<Button <Button
title="Upload And Restore Backup" title="Upload And Restore Backup"
color="warning" color="warning"
beforeIcon="Upload" beforeIcon={<Upload />}
loading={loading} loading={loading}
disabled={!file || loading} disabled={!file || loading}
onClick={() => { onClick={() => {
@@ -0,0 +1,61 @@
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 { BotConversationRequestSchema } from "@/src/schemas/bot-schema";
import type { BotHarnessOverviewPayload } 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";
import isPassThroughUser from "@/src/utils/rbac/is-pass-through-user";
import safeErrorMessage from "@/src/utils/safe-error-message";
import type {
APIResponseObject,
BunextAPIRouteHandler,
} from "@moduletrace/bunext/types";
/**
* What a conversation's models modal edits, loaded when it opens: the
* coordinator's defaults (super admins), or a worker's or the workspace's
* models (super admins and admins).
*/
export const handler: BunextAPIRouteHandler<
APIResponseObject<BotHarnessOverviewPayload>
> = async ({ req, body }) => {
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 };
}
const { bot_id, workspace_id } = parseWithSchema({
schema: BotConversationRequestSchema,
data: body,
});
const conversation = await grabUserConversation({ user_id: user.id, bot_id, workspace_id });
const scope = conversation.workspace_id;
const can_edit =
conversation.bot.kind == "coordinator" && !scope
? isPassThroughUser({ user_types })
: canManageCrew({ user_types });
if (!can_edit) {
throw new AppError(`Only an admin can change these models.`);
}
return {
success: true,
singleRes: await grabConversationHarnessOverview({
bot: conversation.bot,
workspace_id: scope,
}),
};
} catch (error: unknown) {
return { success: false, msg: safeErrorMessage({ error }) };
}
};
+4 -1
View File
@@ -11,6 +11,7 @@ import liveRelay from "./live-relay";
import enableForeignKeys from "./functions/backend/db/enable-foreign-keys"; import enableForeignKeys from "./functions/backend/db/enable-foreign-keys";
import handleComputerProxy from "./functions/server/computers/handle-computer-proxy"; import handleComputerProxy from "./functions/server/computers/handle-computer-proxy";
import handlePwaFile from "./functions/server/pwa/handle-pwa-file"; import handlePwaFile from "./functions/server/pwa/handle-pwa-file";
import compressResponse from "./functions/server/compress-response";
import serveLocalCa from "./functions/server/serve-local-ca"; import serveLocalCa from "./functions/server/serve-local-ca";
import startHttpRedirect from "./functions/server/start-http-redirect"; import startHttpRedirect from "./functions/server/start-http-redirect";
import resolveTlsOptions from "./utils/resolve-tls-options"; import resolveTlsOptions from "./utils/resolve-tls-options";
@@ -147,7 +148,9 @@ const server = Bun.serve({
return; return;
} }
return await bunext.bunextRequestHandler({ req, server }); const response = await bunext.bunextRequestHandler({ req, server });
return response ? await compressResponse({ req, response }) : response;
} catch (error: any) { } catch (error: any) {
await logError({ await logError({
title: "Unhandled request error", title: "Unhandled request error",
+13
View File
@@ -640,6 +640,14 @@ export type BotVoicePayload = {
speech: boolean; speech: boolean;
}; };
/**
* The text model a conversation's bot runs on now, for its header.
*/
export type BotTextModel = {
name: string;
provider_name: string;
};
export type PagePropsType = { export type PagePropsType = {
user?: User | null; user?: User | null;
user_types?: BUN_SQLITE_COBBLE_USER_TYPES[] | null; user_types?: BUN_SQLITE_COBBLE_USER_TYPES[] | null;
@@ -724,6 +732,11 @@ export type PagePropsType = {
* (speech) are turned on for the bot there. * (speech) are turned on for the bot there.
*/ */
bot_voice?: BotVoicePayload | null; bot_voice?: BotVoicePayload | null;
/**
* The open conversation's text model (its header). The full model
* settings load when the models modal opens.
*/
bot_text_model?: BotTextModel | null;
workspace_extensions?: BotExtensionsPayload | null; workspace_extensions?: BotExtensionsPayload | null;
envs?: { envs?: {
GOOGLE_CLIENT_ID?: string; GOOGLE_CLIENT_ID?: string;
+23
View File
@@ -0,0 +1,23 @@
import { describe, expect, test } from "bun:test";
import isCompressibleType from "./is-compressible-type";
describe("isCompressibleType", () => {
test("takes text, scripts, JSON and SVG", () => {
expect(isCompressibleType({ content_type: "text/html; charset=utf-8" })).toBe(true);
expect(isCompressibleType({ content_type: "text/javascript;charset=utf-8" })).toBe(true);
expect(isCompressibleType({ content_type: "text/css" })).toBe(true);
expect(isCompressibleType({ content_type: "application/json" })).toBe(true);
expect(isCompressibleType({ content_type: "application/javascript" })).toBe(true);
expect(isCompressibleType({ content_type: "application/manifest+json" })).toBe(true);
expect(isCompressibleType({ content_type: "image/svg+xml" })).toBe(true);
});
test("leaves media, binaries and streams", () => {
expect(isCompressibleType({ content_type: "text/event-stream" })).toBe(false);
expect(isCompressibleType({ content_type: "audio/mpeg" })).toBe(false);
expect(isCompressibleType({ content_type: "image/png" })).toBe(false);
expect(isCompressibleType({ content_type: "application/octet-stream" })).toBe(false);
expect(isCompressibleType({ content_type: "font/woff2" })).toBe(false);
expect(isCompressibleType({ content_type: null })).toBe(false);
});
});
+18
View File
@@ -0,0 +1,18 @@
type Params = {
content_type?: string | null;
};
/**
* Whether a response of this content type is text worth compressing:
* pages, scripts, styles, JSON, SVG and the like. Media, fonts and
* streams (`text/event-stream`) are left as they are.
*/
export default function isCompressibleType({ content_type }: Params): boolean {
const type = content_type?.split(";")[0]?.trim().toLowerCase();
if (!type || type == "text/event-stream") return false;
if (type.startsWith("text/")) return true;
return /^application\/(javascript|json|manifest\+json|xml|[\w.-]+\+json|[\w.-]+\+xml)$/.test(type) ||
type == "image/svg+xml";
}
+20
View File
@@ -0,0 +1,20 @@
import { describe, expect, test } from "bun:test";
import isHashedAssetPath from "./is-hashed-asset-path";
describe("isHashedAssetPath", () => {
test("matches content-named page scripts and styles", () => {
expect(isHashedAssetPath({ pathname: "/.bunext/public/pages/chunk-AEHOZS24.js" })).toBe(true);
expect(isHashedAssetPath({ pathname: "/.bunext/public/pages/auth/login/S2GYKY3T.js" })).toBe(true);
expect(isHashedAssetPath({ pathname: "/.bunext/public/pages/auth/login/X4TQCLSA.css" })).toBe(true);
expect(
isHashedAssetPath({ pathname: "/.bunext/public/pages/admin/bots/%5Bbot_id%5D/VXCMP4GM.js" }),
).toBe(true);
});
test("leaves fixed names and everything else", () => {
expect(isHashedAssetPath({ pathname: "/.bunext/public/vendor/react.js" })).toBe(false);
expect(isHashedAssetPath({ pathname: "/.bunext/public/pages/index.js" })).toBe(false);
expect(isHashedAssetPath({ pathname: "/admin/bots" })).toBe(false);
expect(isHashedAssetPath({ pathname: "/public/AEHOZS24.js" })).toBe(false);
});
});
+16
View File
@@ -0,0 +1,16 @@
/**
* Page scripts and styles bunext names after their content
* (`chunk-AEHOZS24.js`, `admin/bots/H6VDJMBN.js`, `X4TQCLSA.css`): one
* at such a path never changes, so it can be cached for good. The
* vendor files (`/.bunext/public/vendor/react.js`) keep fixed names and
* are not. `public/sw.js` repeats this pattern.
*/
const HashedAssetPattern = /^\/\.bunext\/public\/pages\/(?:.*\/)?(?:chunk-)?[A-Z0-9]{8}\.(?:js|css)$/;
type Params = {
pathname: string;
};
export default function isHashedAssetPath({ pathname }: Params): boolean {
return HashedAssetPattern.test(pathname);
}
+29
View File
@@ -0,0 +1,29 @@
import { describe, expect, test } from "bun:test";
import pickContentEncoding from "./pick-content-encoding";
describe("pickContentEncoding", () => {
test("prefers brotli", () => {
expect(pickContentEncoding({ accept_encoding: "gzip, deflate, br, zstd" })).toBe("br");
});
test("falls back to gzip", () => {
expect(pickContentEncoding({ accept_encoding: "gzip, deflate" })).toBe("gzip");
expect(pickContentEncoding({ accept_encoding: "*", offer: ["gzip"] })).toBe("gzip");
});
test("only offers what the caller can produce", () => {
expect(pickContentEncoding({ accept_encoding: "br, gzip", offer: ["gzip"] })).toBe("gzip");
expect(pickContentEncoding({ accept_encoding: "br", offer: ["gzip"] })).toBe(null);
});
test("is none without a supported coding", () => {
expect(pickContentEncoding({ accept_encoding: "identity" })).toBe(null);
expect(pickContentEncoding({ accept_encoding: "" })).toBe(null);
expect(pickContentEncoding({ accept_encoding: null })).toBe(null);
});
test("skips refused codings", () => {
expect(pickContentEncoding({ accept_encoding: "br;q=0, gzip;q=0.8" })).toBe("gzip");
expect(pickContentEncoding({ accept_encoding: "br;q=0.0, gzip;q=0" })).toBe(null);
});
});
+32
View File
@@ -0,0 +1,32 @@
export type ContentEncoding = "br" | "gzip";
type Params = {
accept_encoding?: string | null;
/**
* The encodings the caller can produce, preferred first.
*/
offer?: ContentEncoding[];
};
/**
* The encoding to compress a response with for this `Accept-Encoding`:
* the first of `offer` (brotli, then gzip) the client takes, else none. A
* coding listed with `q=0` is refused, and `*` takes anything.
*/
export default function pickContentEncoding({
accept_encoding,
offer = ["br", "gzip"],
}: Params): ContentEncoding | null {
if (!accept_encoding) return null;
const accepted = new Set(
accept_encoding
.toLowerCase()
.split(",")
.map((part) => part.trim().split(";").map((piece) => piece.trim()))
.filter(([, ...params]) => !params.some((param) => /^q=0(\.0*)?$/.test(param)))
.map(([coding]) => coding),
);
return offer.find((encoding) => accepted.has(encoding) || accepted.has("*")) ?? null;
}