Finish the mobile performance pass
- Cache the model catalog once per user and each conversation's setting separately; clear both on logout and after model saves - Compress hashed assets asynchronously with a byte budget and set Vary - Memoize message parsing and throttle selection reads - Debounce draft writes to localStorage - Keep the composer editor on the page and focused, as before - Add a bundle budget check and run it in CI - Show a loading block while sessions load and a workspace avatar in the mobile conversation header
This commit is contained in:
@@ -33,3 +33,9 @@ jobs:
|
|||||||
|
|
||||||
- name: Test
|
- name: Test
|
||||||
run: bun run test
|
run: bun run test
|
||||||
|
|
||||||
|
- name: Build pages
|
||||||
|
run: NODE_ENV=production bunx bunext build
|
||||||
|
|
||||||
|
- name: Check bundle budget
|
||||||
|
run: bun run check:bundle
|
||||||
|
|||||||
@@ -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:
|
`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
|
- 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/`
|
- `/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
|
||||||
@@ -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`) |
|
| 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) |
|
| 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` |
|
||||||
|
| Bundle budget (after a build) | `bun run check:bundle` |
|
||||||
| 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`) |
|
||||||
| DB schema (tables) | `bun run db:schema` |
|
| DB schema (tables) | `bun run db:schema` |
|
||||||
@@ -92,7 +93,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, 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).
|
- **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
-1
@@ -16,7 +16,8 @@
|
|||||||
"seed:bots": "bun src/scripts/seed-bots.ts",
|
"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",
|
"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",
|
||||||
|
"check:bundle": "bun src/scripts/check-bundle-size.ts"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@next/mdx": "^16.3.0",
|
"@next/mdx": "^16.3.0",
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import type { BotWithLastMessage } from "@/src/types";
|
|||||||
import useBotSessions from "@/src/hooks/use-bot-sessions";
|
import useBotSessions from "@/src/hooks/use-bot-sessions";
|
||||||
import BotSessionRow from "./bot-session-row";
|
import BotSessionRow from "./bot-session-row";
|
||||||
import HeaderIconButton from "./header-icon-button";
|
import HeaderIconButton from "./header-icon-button";
|
||||||
|
import LoadingBlock from "@moduletrace/twui/elements/LoadingBlock";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
bot: BotWithLastMessage;
|
bot: BotWithLastMessage;
|
||||||
@@ -68,7 +69,7 @@ export default function BotSessionsModal({
|
|||||||
}
|
}
|
||||||
open={open}
|
open={open}
|
||||||
setOpen={setOpen}
|
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!"
|
||||||
>
|
>
|
||||||
<Row className="w-full gap-3! flex-nowrap items-start justify-between">
|
<Row className="w-full gap-3! flex-nowrap items-start justify-between">
|
||||||
<Stack className="min-w-0 gap-1! items-stretch">
|
<Stack className="min-w-0 gap-1! items-stretch">
|
||||||
@@ -124,13 +125,15 @@ export default function BotSessionsModal({
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
{sessions.sessions.length || sessions.loading ? null : (
|
{sessions.loading ? (
|
||||||
|
<LoadingBlock className="w-full h-[300px]" />
|
||||||
|
) : sessions.sessions.length ? (
|
||||||
<Span className="px-3 py-6 text-[13px] leading-[18px] text-ink-secondary">
|
<Span className="px-3 py-6 text-[13px] leading-[18px] text-ink-secondary">
|
||||||
{sessions.query.trim()
|
{sessions.query.trim()
|
||||||
? `No session matches "${sessions.query.trim()}".`
|
? `No session matches "${sessions.query.trim()}".`
|
||||||
: "No sessions yet."}
|
: "No sessions yet."}
|
||||||
</Span>
|
</Span>
|
||||||
)}
|
) : null}
|
||||||
</Stack>
|
</Stack>
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
|
|||||||
+17
-8
@@ -12,11 +12,18 @@ export const AppData = {
|
|||||||
*/
|
*/
|
||||||
ComposerDraftStorageKey: `${SiteData["SiteSlug"]}_composer_draft`,
|
ComposerDraftStorageKey: `${SiteData["SiteSlug"]}_composer_draft`,
|
||||||
/**
|
/**
|
||||||
* Prefix of the chat header's models modal data in `localStorage`, one
|
* Prefix of the chat header's model data in `localStorage`: one catalog
|
||||||
* per bot and workspace, and how long it is shown before it is fetched
|
* per user, and one small setting per bot and workspace.
|
||||||
* again.
|
* `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,
|
HarnessOverviewCacheMs: 10 * 60 * 1000,
|
||||||
ServerHealthMessage: "Server Running Well",
|
ServerHealthMessage: "Server Running Well",
|
||||||
/**
|
/**
|
||||||
@@ -366,12 +373,14 @@ export const AppData = {
|
|||||||
/**
|
/**
|
||||||
* Brotli quality for page scripts and styles, compressed once per
|
* Brotli quality for page scripts and styles, compressed once per
|
||||||
* file and kept in memory (`compressResponse`). 11 is about 25 times
|
* 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
|
* How many compressed page scripts and styles, and how many bytes of
|
||||||
* rebuild names its files anew, so the oldest go first.
|
* them, stay in memory. A rebuild names its files anew, so the oldest
|
||||||
|
* go first.
|
||||||
*/
|
*/
|
||||||
CompressCacheMaxFiles: 600,
|
CompressCacheMaxFiles: 600,
|
||||||
|
CompressCacheMaxBytes: 32 * 1024 * 1024,
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@@ -26,8 +26,10 @@ type Params = {
|
|||||||
* read) and its messages, the text model it runs on, its computers,
|
* 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
|
* 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
|
* 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
|
* the models modal: the install-wide catalog (`api/admin/harness/catalog`)
|
||||||
* a page's weight and only that modal uses them.
|
* 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.
|
* Null when the user can't chat with the bot there.
|
||||||
*/
|
*/
|
||||||
export default async function grabConversationProps({
|
export default async function grabConversationProps({
|
||||||
|
|||||||
@@ -1,33 +0,0 @@
|
|||||||
import type { BotHarnessOverviewPayload } from "@/src/types";
|
|
||||||
import grabBotHarnessOverview from "./grab-bot-harness-overview";
|
|
||||||
import grabHarnessOverview from "./grab-harness-overview";
|
|
||||||
|
|
||||||
type Params = {
|
|
||||||
/**
|
|
||||||
* A bot the caller has checked the session user chats with there.
|
|
||||||
*/
|
|
||||||
bot: { id?: number; kind?: string | null };
|
|
||||||
workspace_id?: number | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* What a conversation's models modal edits: a worker's own settings, or
|
|
||||||
* for the coordinator the defaults it runs on (without the providers). In
|
|
||||||
* a workspace the workspace's own models come first, so what runs now
|
|
||||||
* shows its pick.
|
|
||||||
*/
|
|
||||||
export default async function grabConversationHarnessOverview({
|
|
||||||
bot,
|
|
||||||
workspace_id,
|
|
||||||
}: Params): Promise<BotHarnessOverviewPayload> {
|
|
||||||
if (bot.kind == "coordinator" && !workspace_id) {
|
|
||||||
const { harnesses } = await grabHarnessOverview();
|
|
||||||
|
|
||||||
return { harnesses };
|
|
||||||
}
|
|
||||||
|
|
||||||
return await grabBotHarnessOverview({
|
|
||||||
bot_id: bot.kind == "coordinator" ? undefined : bot.id,
|
|
||||||
workspace_id: workspace_id || undefined,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import type { BotHarnessSettingsPayload, HarnessSettingOverview } from "@/src/types";
|
||||||
|
import grabBotHarnessOverview from "./grab-bot-harness-overview";
|
||||||
|
import grabHarnessOverview from "./grab-harness-overview";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
/**
|
||||||
|
* A bot the caller has checked the session user chats with there.
|
||||||
|
*/
|
||||||
|
bot: { id?: number; kind?: string | null };
|
||||||
|
workspace_id?: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What a conversation's models form edits, without the install-wide
|
||||||
|
* catalog: a worker's own settings, or for the coordinator the defaults it
|
||||||
|
* runs on. In a workspace the workspace's own models come first, so what
|
||||||
|
* runs now shows its pick.
|
||||||
|
*/
|
||||||
|
export default async function grabConversationHarnessSettings({
|
||||||
|
bot,
|
||||||
|
workspace_id,
|
||||||
|
}: Params): Promise<BotHarnessSettingsPayload> {
|
||||||
|
const overview =
|
||||||
|
bot.kind == "coordinator" && !workspace_id
|
||||||
|
? { harnesses: (await grabHarnessOverview()).harnesses }
|
||||||
|
: await grabBotHarnessOverview({
|
||||||
|
bot_id: bot.kind == "coordinator" ? undefined : bot.id,
|
||||||
|
workspace_id: workspace_id || undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { harnesses: overview.harnesses.map(stripCatalog) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A harness overview item without the models and providers the catalog
|
||||||
|
* already carries. A picked model or provider the catalog no longer lists
|
||||||
|
* travels with the setting, so the form can still show it.
|
||||||
|
*/
|
||||||
|
function stripCatalog({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
required,
|
||||||
|
automatic,
|
||||||
|
providers,
|
||||||
|
models,
|
||||||
|
...setting
|
||||||
|
}: (Awaited<ReturnType<typeof grabBotHarnessOverview>>)["harnesses"][number]): HarnessSettingOverview {
|
||||||
|
const pinned_model = setting.model_id
|
||||||
|
? models.find((model) => model.id == setting.model_id)
|
||||||
|
: undefined;
|
||||||
|
const unavailable_provider =
|
||||||
|
setting.provider_id && !providers.some((provider) => provider.id == setting.provider_id)
|
||||||
|
? { id: setting.provider_id, name: "Unavailable provider" }
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...setting,
|
||||||
|
...(pinned_model ? { pinned_model } : {}),
|
||||||
|
...(unavailable_provider ? { unavailable_provider } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { Harnesses } from "@/src/dict/harness-dict";
|
||||||
|
import modelServesHarness from "@/src/harness/catalog/model-serves-harness";
|
||||||
|
import type {
|
||||||
|
HarnessCatalogPayload,
|
||||||
|
HarnessProviderChoice,
|
||||||
|
} from "@/src/types";
|
||||||
|
import grabHarnessModelEntries from "./grab-harness-model-entries";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every harness's install-wide catalog: what it is for, the providers with
|
||||||
|
* a model that can do the job, and those models. One copy serves every
|
||||||
|
* bot and workspace, so the models modal caches it once per user.
|
||||||
|
*/
|
||||||
|
export default async function grabHarnessCatalog(): Promise<HarnessCatalogPayload> {
|
||||||
|
const entries = await grabHarnessModelEntries();
|
||||||
|
|
||||||
|
return {
|
||||||
|
harnesses: Harnesses.map(({ value, title, description, required, automatic }) => {
|
||||||
|
const serving = entries.filter(({ model }) =>
|
||||||
|
modelServesHarness({ model, harness: value }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const providers: HarnessProviderChoice[] = [
|
||||||
|
...new Map(
|
||||||
|
serving.map(({ option }) => [
|
||||||
|
option.provider_id,
|
||||||
|
{ id: option.provider_id, name: option.provider_name },
|
||||||
|
]),
|
||||||
|
).values(),
|
||||||
|
];
|
||||||
|
|
||||||
|
return {
|
||||||
|
harness: value,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
required,
|
||||||
|
automatic,
|
||||||
|
providers,
|
||||||
|
models: serving.map(({ option }) => option),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import fetchApi from "@moduletrace/twui/utils/fetch/fetchApi";
|
|||||||
import type { APIResponseObject } from "@moduletrace/bunext/types";
|
import type { APIResponseObject } from "@moduletrace/bunext/types";
|
||||||
import type { SaveBotHarnessSettingsRequest } from "@/src/schemas/harness-schema";
|
import type { SaveBotHarnessSettingsRequest } from "@/src/schemas/harness-schema";
|
||||||
import type { BotHarnessOverviewPayload } from "@/src/types";
|
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.
|
* 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(
|
export default async function saveBotHarnessSettings(
|
||||||
body: SaveBotHarnessSettingsRequest,
|
body: SaveBotHarnessSettingsRequest,
|
||||||
) {
|
) {
|
||||||
return await fetchApi<
|
const res = await fetchApi<
|
||||||
SaveBotHarnessSettingsRequest,
|
SaveBotHarnessSettingsRequest,
|
||||||
APIResponseObject<BotHarnessOverviewPayload>
|
APIResponseObject<BotHarnessOverviewPayload>
|
||||||
>(`/api/admin/bots/save-harness-settings`, {
|
>(`/api/admin/bots/save-harness-settings`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body,
|
body,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (res.success) clearHarnessCache();
|
||||||
|
|
||||||
|
return res;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,16 +2,21 @@ import fetchApi from "@moduletrace/twui/utils/fetch/fetchApi";
|
|||||||
import type { APIResponseObject } from "@moduletrace/bunext/types";
|
import type { APIResponseObject } from "@moduletrace/bunext/types";
|
||||||
import type { SaveWorkspaceHarnessSettingsRequest } from "@/src/schemas/harness-schema";
|
import type { SaveWorkspaceHarnessSettingsRequest } from "@/src/schemas/harness-schema";
|
||||||
import type { BotHarnessOverviewPayload } from "@/src/types";
|
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.
|
* Saves a workspace's model settings and returns the fresh page data.
|
||||||
*/
|
*/
|
||||||
export default async function saveWorkspaceHarnessSettings(body: SaveWorkspaceHarnessSettingsRequest) {
|
export default async function saveWorkspaceHarnessSettings(body: SaveWorkspaceHarnessSettingsRequest) {
|
||||||
return await fetchApi<
|
const res = await fetchApi<
|
||||||
SaveWorkspaceHarnessSettingsRequest,
|
SaveWorkspaceHarnessSettingsRequest,
|
||||||
APIResponseObject<BotHarnessOverviewPayload>
|
APIResponseObject<BotHarnessOverviewPayload>
|
||||||
>(`/api/admin/workspaces/save-harness-settings`, {
|
>(`/api/admin/workspaces/save-harness-settings`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body,
|
body,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (res.success) clearHarnessCache();
|
||||||
|
|
||||||
|
return res;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
import { brotliCompressSync, constants } from "node:zlib";
|
import { brotliCompress, constants } from "node:zlib";
|
||||||
|
import { promisify } from "node:util";
|
||||||
import { AppData } from "@/src/data/app-data";
|
import { AppData } from "@/src/data/app-data";
|
||||||
import isCompressibleType from "@/src/utils/is-compressible-type";
|
import isCompressibleType from "@/src/utils/is-compressible-type";
|
||||||
import isHashedAssetPath from "@/src/utils/is-hashed-asset-path";
|
import isHashedAssetPath from "@/src/utils/is-hashed-asset-path";
|
||||||
import pickContentEncoding, { type ContentEncoding } from "@/src/utils/pick-content-encoding";
|
import pickContentEncoding, { type ContentEncoding } from "@/src/utils/pick-content-encoding";
|
||||||
|
|
||||||
|
const BrotliCompress = promisify(brotliCompress);
|
||||||
|
|
||||||
type Params = {
|
type Params = {
|
||||||
req: Request;
|
req: Request;
|
||||||
response: Response;
|
response: Response;
|
||||||
@@ -14,6 +17,12 @@ type Params = {
|
|||||||
* names are their content, so an entry never goes stale.
|
* names are their content, so an entry never goes stale.
|
||||||
*/
|
*/
|
||||||
const HashedAssetCache = new Map<string, Uint8Array<ArrayBuffer>>();
|
const HashedAssetCache = new Map<string, Uint8Array<ArrayBuffer>>();
|
||||||
|
let HashedAssetCacheBytes = 0;
|
||||||
|
/**
|
||||||
|
* Compressions in progress, so parallel requests for a cold asset share
|
||||||
|
* one pass rather than each blocking a brotli run.
|
||||||
|
*/
|
||||||
|
const HashedAssetInflight = new Map<string, Promise<Uint8Array<ArrayBuffer>>>();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Compresses a text response for a client that takes it, and caches the
|
* Compresses a text response for a client that takes it, and caches the
|
||||||
@@ -22,8 +31,7 @@ const HashedAssetCache = new Map<string, Uint8Array<ArrayBuffer>>();
|
|||||||
*
|
*
|
||||||
* Page scripts and styles are compressed whole (brotli or gzip) and kept
|
* Page scripts and styles are compressed whole (brotli or gzip) and kept
|
||||||
* in memory; pages and API responses are gzipped as they stream. Media,
|
* in memory; pages and API responses are gzipped as they stream. Media,
|
||||||
* streams, small bodies and anything already encoded go out as they
|
* streams, small bodies and anything already encoded go out as they are.
|
||||||
* are.
|
|
||||||
*/
|
*/
|
||||||
export default async function compressResponse({ req, response }: Params): Promise<Response> {
|
export default async function compressResponse({ req, response }: Params): Promise<Response> {
|
||||||
const { pathname } = new URL(req.url);
|
const { pathname } = new URL(req.url);
|
||||||
@@ -31,6 +39,11 @@ export default async function compressResponse({ req, response }: Params): Promi
|
|||||||
|
|
||||||
if (hashed) {
|
if (hashed) {
|
||||||
response.headers.set("Cache-Control", "public, max-age=31536000, immutable");
|
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 (
|
if (
|
||||||
@@ -58,7 +71,7 @@ export default async function compressResponse({ req, response }: Params): Promi
|
|||||||
headers.set("Content-Encoding", encoding);
|
headers.set("Content-Encoding", encoding);
|
||||||
headers.delete("Content-Length");
|
headers.delete("Content-Length");
|
||||||
headers.delete("Content-Disposition");
|
headers.delete("Content-Disposition");
|
||||||
headers.append("Vary", "Accept-Encoding");
|
appendVary({ headers });
|
||||||
|
|
||||||
if (hashed) {
|
if (hashed) {
|
||||||
const body = await compressHashedAsset({ pathname, encoding, response });
|
const body = await compressHashedAsset({ pathname, encoding, response });
|
||||||
@@ -90,26 +103,87 @@ async function compressHashedAsset({
|
|||||||
return cached;
|
return cached;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const inflight = HashedAssetInflight.get(key);
|
||||||
|
|
||||||
|
if (inflight) {
|
||||||
|
response.body?.cancel().catch(() => {});
|
||||||
|
return await inflight;
|
||||||
|
}
|
||||||
|
|
||||||
|
const compression = compressBody({ encoding, response });
|
||||||
|
|
||||||
|
HashedAssetInflight.set(key, compression);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body = await compression;
|
||||||
|
|
||||||
|
rememberHashedAsset({ key, body });
|
||||||
|
|
||||||
|
return body;
|
||||||
|
} finally {
|
||||||
|
HashedAssetInflight.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function compressBody({
|
||||||
|
encoding,
|
||||||
|
response,
|
||||||
|
}: {
|
||||||
|
encoding: ContentEncoding;
|
||||||
|
response: Response;
|
||||||
|
}): Promise<Uint8Array<ArrayBuffer>> {
|
||||||
const bytes = new Uint8Array(await response.arrayBuffer());
|
const bytes = new Uint8Array(await response.arrayBuffer());
|
||||||
const body =
|
|
||||||
encoding == "br"
|
if (encoding == "gzip") return new Uint8Array(Bun.gzipSync(bytes, { level: 9 }));
|
||||||
? new Uint8Array(
|
|
||||||
brotliCompressSync(bytes, {
|
return new Uint8Array(
|
||||||
|
await BrotliCompress(bytes, {
|
||||||
params: {
|
params: {
|
||||||
[constants.BROTLI_PARAM_QUALITY]: AppData["CompressBrotliQuality"],
|
[constants.BROTLI_PARAM_QUALITY]: AppData["CompressBrotliQuality"],
|
||||||
[constants.BROTLI_PARAM_SIZE_HINT]: bytes.length,
|
[constants.BROTLI_PARAM_SIZE_HINT]: bytes.length,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
)
|
);
|
||||||
: Bun.gzipSync(bytes, { level: 9 });
|
}
|
||||||
|
|
||||||
|
function rememberHashedAsset({
|
||||||
|
key,
|
||||||
|
body,
|
||||||
|
}: {
|
||||||
|
key: string;
|
||||||
|
body: Uint8Array<ArrayBuffer>;
|
||||||
|
}) {
|
||||||
HashedAssetCache.set(key, body);
|
HashedAssetCache.set(key, body);
|
||||||
|
HashedAssetCacheBytes += body.byteLength;
|
||||||
|
|
||||||
if (HashedAssetCache.size > AppData["CompressCacheMaxFiles"]) {
|
while (
|
||||||
|
HashedAssetCache.size > AppData["CompressCacheMaxFiles"] ||
|
||||||
|
HashedAssetCacheBytes > AppData["CompressCacheMaxBytes"]
|
||||||
|
) {
|
||||||
const oldest = HashedAssetCache.keys().next().value;
|
const oldest = HashedAssetCache.keys().next().value;
|
||||||
|
|
||||||
if (oldest) HashedAssetCache.delete(oldest);
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
return body;
|
if (!vary.split(",").some((part) => part.trim().toLowerCase() == "accept-encoding")) {
|
||||||
|
headers.set("Vary", `${vary}, Accept-Encoding`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<string, never>,
|
||||||
|
APIResponseObject<HarnessCatalogPayload>
|
||||||
|
>(`/api/admin/harness/catalog`, {
|
||||||
|
method: "POST",
|
||||||
|
body: {},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,16 +1,16 @@
|
|||||||
import fetchApi from "@moduletrace/twui/utils/fetch/fetchApi";
|
import fetchApi from "@moduletrace/twui/utils/fetch/fetchApi";
|
||||||
import type { APIResponseObject } from "@moduletrace/bunext/types";
|
import type { APIResponseObject } from "@moduletrace/bunext/types";
|
||||||
import type { BotConversationRequest } from "@/src/schemas/bot-schema";
|
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
|
* What a conversation's models form edits, without the install-wide
|
||||||
* models it can pick and what it runs on now.
|
* catalog: the setting, what runs now, and what the default runs on.
|
||||||
*/
|
*/
|
||||||
export default async function fetchHarnessOverview({ bot_id, workspace_id }: BotConversationRequest) {
|
export default async function fetchHarnessOverview({ bot_id, workspace_id }: BotConversationRequest) {
|
||||||
return await fetchApi<
|
return await fetchApi<
|
||||||
BotConversationRequest,
|
BotConversationRequest,
|
||||||
APIResponseObject<BotHarnessOverviewPayload>
|
APIResponseObject<BotHarnessSettingsPayload>
|
||||||
>(`/api/admin/bots/harness-overview`, {
|
>(`/api/admin/bots/harness-overview`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: { bot_id, workspace_id },
|
body: { bot_id, workspace_id },
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import composerDraftKey from "../(utils)/composer-draft-key";
|
import composerDraftKey from "../(utils)/composer-draft-key";
|
||||||
|
|
||||||
type Params = {
|
type Params = {
|
||||||
@@ -6,17 +6,46 @@ type Params = {
|
|||||||
workspace_id?: number | null;
|
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
|
* The composer's text (markdown), kept in `localStorage` per bot and
|
||||||
* workspace until it's sent, so a reload or leaving the conversation
|
* 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
|
* 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) {
|
export default function useComposerDraft({ bot_id, workspace_id }: Params) {
|
||||||
const key = composerDraftKey({ bot_id, workspace_id, part: "text" });
|
const key = composerDraftKey({ bot_id, workspace_id, part: "text" });
|
||||||
const [draft, setDraft] = useState({ key, text: "", loaded: false });
|
const [draft, setDraft] = useState({ key, text: "", loaded: false });
|
||||||
|
const timer = useRef<ReturnType<typeof setTimeout> | 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(() => {
|
useEffect(() => {
|
||||||
|
flush();
|
||||||
|
|
||||||
let text = "";
|
let text = "";
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -26,13 +55,16 @@ export default function useComposerDraft({ bot_id, workspace_id }: Params) {
|
|||||||
setDraft({ key, text, loaded: true });
|
setDraft({ key, text, loaded: true });
|
||||||
}, [key]);
|
}, [key]);
|
||||||
|
|
||||||
|
useEffect(() => () => flush(), []);
|
||||||
|
|
||||||
const setText = (text: string) => {
|
const setText = (text: string) => {
|
||||||
setDraft({ key, text, loaded: true });
|
setDraft({ key, text, loaded: true });
|
||||||
|
|
||||||
try {
|
pending.current = { key, text };
|
||||||
if (text) localStorage.setItem(key, text);
|
|
||||||
else localStorage.removeItem(key);
|
if (timer.current) clearTimeout(timer.current);
|
||||||
} catch {}
|
|
||||||
|
timer.current = setTimeout(flush, WriteDelayMs);
|
||||||
};
|
};
|
||||||
|
|
||||||
const current = draft.key == key;
|
const current = draft.key == key;
|
||||||
|
|||||||
@@ -1,13 +1,26 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { AppData } from "@/src/data/app-data";
|
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 fetchHarnessOverview from "../(functions)/fetch-harness-overview";
|
||||||
import {
|
import {
|
||||||
readHarnessOverviewCache,
|
harnessCatalogFromOverview,
|
||||||
writeHarnessOverviewCache,
|
harnessSettingsFromOverview,
|
||||||
} from "../(utils)/harness-overview-cache";
|
mergeHarnessOverview,
|
||||||
|
} from "../(utils)/harness-overview-parts";
|
||||||
|
|
||||||
type Params = {
|
type Params = {
|
||||||
|
user_id: number;
|
||||||
bot_id: number;
|
bot_id: number;
|
||||||
workspace_id?: number | null;
|
workspace_id?: number | null;
|
||||||
/**
|
/**
|
||||||
@@ -18,55 +31,118 @@ type Params = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The models modal's data for a conversation. Shows what `localStorage`
|
* The models modal's data for a conversation. The install-wide catalog is
|
||||||
* last had at once, and fetches it again when there is none or it is
|
* cached once per user; the conversation's own setting is cached per bot
|
||||||
* older than `AppData.HarnessOverviewCacheMs`. `setHarnesses` takes a
|
* and workspace, and is small. Either shows what `localStorage` last had
|
||||||
* save's fresh data and keeps it.
|
* 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) {
|
export default function useHarnessOverview({
|
||||||
const [harnesses, setState] = useState<HarnessOverview[]>();
|
user_id,
|
||||||
|
bot_id,
|
||||||
|
workspace_id,
|
||||||
|
enabled,
|
||||||
|
}: Params) {
|
||||||
|
const [catalog, setCatalog] = useState<HarnessCatalog[]>();
|
||||||
|
const [settings, setSettings] = useState<HarnessSettingOverview[]>();
|
||||||
const [error, setError] = useState<string>();
|
const [error, setError] = useState<string>();
|
||||||
/**
|
/**
|
||||||
* Counts fetched updates, so a form showing the cached data remounts
|
* Counts fetched settings, so a form showing cached data remounts with
|
||||||
* with the fresh.
|
* the fresh when it isn't being edited.
|
||||||
*/
|
*/
|
||||||
const [version, setVersion] = useState(0);
|
const [version, setVersion] = useState(0);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!enabled) return;
|
if (!enabled || !user_id || !bot_id) return;
|
||||||
|
|
||||||
let cancelled = false;
|
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_catalog) setCatalog(cached_catalog.data.harnesses);
|
||||||
if (cached && Date.now() - cached.at < AppData.HarnessOverviewCacheMs) return;
|
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);
|
setError(undefined);
|
||||||
|
|
||||||
fetchHarnessOverview({ bot_id, workspace_id })
|
const loadCatalog = async () => {
|
||||||
.then((res) => {
|
try {
|
||||||
|
const res = await fetchHarnessCatalog();
|
||||||
|
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
|
|
||||||
if (res.success && res.singleRes) {
|
if (res.success && res.singleRes) {
|
||||||
setState(res.singleRes.harnesses);
|
setCatalog(res.singleRes.harnesses);
|
||||||
setVersion((prev) => prev + 1);
|
writeHarnessCatalogCache({ user_id, data: res.singleRes });
|
||||||
writeHarnessOverviewCache({ bot_id, workspace_id, harnesses: res.singleRes.harnesses });
|
} else if (!cached_catalog) {
|
||||||
} else if (!cached) {
|
|
||||||
setError(res.msg || "Couldn't load the models.");
|
setError(res.msg || "Couldn't load the models.");
|
||||||
}
|
}
|
||||||
})
|
} catch {
|
||||||
.catch(() => {
|
if (!cancelled && !cached_catalog) setError("Couldn't load the models.");
|
||||||
if (!cancelled && !cached) 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 () => {
|
return () => {
|
||||||
cancelled = true;
|
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[]) => {
|
const setHarnesses = (fresh: HarnessOverview[]) => {
|
||||||
setState(fresh);
|
const next_catalog = harnessCatalogFromOverview({ harnesses: fresh });
|
||||||
writeHarnessOverviewCache({ bot_id, workspace_id, 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 };
|
return { harnesses, error, version, setHarnesses };
|
||||||
|
|||||||
@@ -48,14 +48,29 @@ export default function useMessageSelection({ ref }: Params) {
|
|||||||
setSelection({ text, rect: range.getBoundingClientRect() });
|
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);
|
document.addEventListener("selectionchange", read);
|
||||||
ref.current?.addEventListener("scroll", read, { passive: true });
|
ref.current?.addEventListener("scroll", onScroll, { passive: true });
|
||||||
|
|
||||||
const scroller = ref.current;
|
const scroller = ref.current;
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
document.removeEventListener("selectionchange", read);
|
document.removeEventListener("selectionchange", read);
|
||||||
scroller?.removeEventListener("scroll", read);
|
scroller?.removeEventListener("scroll", onScroll);
|
||||||
|
if (frame) cancelAnimationFrame(frame);
|
||||||
};
|
};
|
||||||
}, [ref]);
|
}, [ref]);
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,12 @@ import MessageReplyQuote from "./message-reply-quote";
|
|||||||
import NeedsYouChip from "./needs-you-chip";
|
import NeedsYouChip from "./needs-you-chip";
|
||||||
import SpeakMessageButton from "./speak-message-button";
|
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 = {
|
type Props = {
|
||||||
message: BotMessageWithAttachments;
|
message: BotMessageWithAttachments;
|
||||||
bot_name: string;
|
bot_name: string;
|
||||||
@@ -52,7 +58,7 @@ function BotMessageLine({ message, bot_name, at }: Props) {
|
|||||||
className="w-full gap-2.5! items-start break-words"
|
className="w-full gap-2.5! items-start break-words"
|
||||||
>
|
>
|
||||||
<Markdown
|
<Markdown
|
||||||
remarkPlugins={[remarkGfm]}
|
remarkPlugins={RemarkPlugins}
|
||||||
components={MessageMarkdownComponents}
|
components={MessageMarkdownComponents}
|
||||||
>
|
>
|
||||||
{linkComputerRefs({ text: message.text || "" })}
|
{linkComputerRefs({ text: message.text || "" })}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useContext, useState } from "react";
|
import { useContext, useEffect, useState } from "react";
|
||||||
import Modal from "@moduletrace/twui/elements/Modal";
|
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";
|
||||||
@@ -41,6 +41,15 @@ export default function BotModelsModal({ bot, workspace, onSaved }: Props) {
|
|||||||
* The pointer or focus reached the icon: start loading before the click.
|
* The pointer or focus reached the icon: start loading before the click.
|
||||||
*/
|
*/
|
||||||
const [warm, setWarm] = useState(false);
|
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 in_workspace = Boolean(workspace?.id);
|
||||||
const is_coordinator = bot.kind == "coordinator" && !in_workspace;
|
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 name = bot.name || "This bot";
|
||||||
|
|
||||||
const { harnesses, error, version, setHarnesses } = useHarnessOverview({
|
const { harnesses, error, version, setHarnesses } = useHarnessOverview({
|
||||||
|
user_id: Number(pageProps?.user?.id || 0),
|
||||||
bot_id: Number(bot.id),
|
bot_id: Number(bot.id),
|
||||||
workspace_id: workspace?.id || null,
|
workspace_id: workspace?.id || null,
|
||||||
enabled: (open || warm) && can_edit,
|
enabled: (open || warm) && can_edit,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!dirty) setFormKey(version);
|
||||||
|
}, [dirty, version]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) setDirty(false);
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
if (!bot.id) return null;
|
if (!bot.id) return null;
|
||||||
|
|
||||||
const target = (
|
const target = (
|
||||||
@@ -107,10 +125,11 @@ export default function BotModelsModal({ bot, workspace, onSaved }: Props) {
|
|||||||
</Stack>
|
</Stack>
|
||||||
{harnesses?.length ? (
|
{harnesses?.length ? (
|
||||||
<BotHarnessForm
|
<BotHarnessForm
|
||||||
key={version}
|
key={form_key}
|
||||||
bot={bot}
|
bot={bot}
|
||||||
workspace_id={workspace?.id || null}
|
workspace_id={workspace?.id || null}
|
||||||
harnesses={harnesses}
|
harnesses={harnesses}
|
||||||
|
onDirtyChange={setDirty}
|
||||||
onSaved={(fresh) => {
|
onSaved={(fresh) => {
|
||||||
setHarnesses(fresh);
|
setHarnesses(fresh);
|
||||||
onSaved(fresh);
|
onSaved(fresh);
|
||||||
|
|||||||
@@ -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 (
|
|
||||||
<Span className="grow min-w-0 self-center min-h-5 text-sm leading-5 text-ink-secondary truncate">
|
|
||||||
{placeholder}
|
|
||||||
</Span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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<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,5 +1,4 @@
|
|||||||
import { lazy, Suspense, useContext, useEffect, useImperativeHandle, useRef, useState, type Ref } from "react";
|
import { 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";
|
||||||
@@ -15,25 +14,18 @@ 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 type { ComposerMentionMenu } from "../(hooks)/use-composer-editor";
|
import useComposerEditor 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 ComposerInputFallback from "./composer-input-fallback";
|
import ComposerEditor from "./composer-editor";
|
||||||
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.
|
||||||
*/
|
*/
|
||||||
@@ -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
|
* 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
|
||||||
@@ -98,16 +90,17 @@ export default function Composer({
|
|||||||
const workspaces = useContext(CrewWorkspacesContext);
|
const workspaces = useContext(CrewWorkspacesContext);
|
||||||
const send_ref = useRef<() => void>(() => {});
|
const send_ref = useRef<() => void>(() => {});
|
||||||
|
|
||||||
const input = useRef<ComposerInputApi | null>(null);
|
const input = useComposerEditor({
|
||||||
const [editor, setEditor] = useState<Editor | null>(null);
|
placeholder: `Message ${bot.name}`,
|
||||||
const [menu, setMenu] = useState<ComposerMentionMenu | null>(null);
|
bots,
|
||||||
const placeholder = `Message ${bot.name}`;
|
workspaces,
|
||||||
|
onChange: ({ markdown }) => setText(markdown),
|
||||||
|
onSubmit: () => send_ref.current(),
|
||||||
|
onFiles: files.add,
|
||||||
|
});
|
||||||
|
const editor = input.editor;
|
||||||
|
|
||||||
useImperativeHandle(
|
useImperativeHandle(handle_ref, () => ({ insertQuote: input.insertQuote }), [input.insertQuote]);
|
||||||
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
|
||||||
@@ -122,7 +115,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.current?.setMarkdown({ markdown: text });
|
input.setMarkdown({ markdown: text });
|
||||||
editor.commands.focus("end");
|
editor.commands.focus("end");
|
||||||
});
|
});
|
||||||
}, [editor, draft.loaded, draft.key]);
|
}, [editor, draft.loaded, draft.key]);
|
||||||
@@ -145,7 +138,7 @@ export default function Composer({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
input.current?.insertText({ text: res.singleRes.text });
|
input.insertText({ text: res.singleRes.text });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -178,7 +171,7 @@ export default function Composer({
|
|||||||
}
|
}
|
||||||
|
|
||||||
setText("");
|
setText("");
|
||||||
input.current?.clear();
|
input.clear();
|
||||||
files.clear();
|
files.clear();
|
||||||
onSent({ message: res.singleRes });
|
onSent({ message: res.singleRes });
|
||||||
}
|
}
|
||||||
@@ -211,7 +204,7 @@ export default function Composer({
|
|||||||
onCancel={onCancelReply}
|
onCancel={onCancelReply}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{menu ? <ComposerMentionList menu={menu} /> : null}
|
{input.menu ? <ComposerMentionList menu={input.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;
|
||||||
@@ -226,21 +219,7 @@ export default function Composer({
|
|||||||
disabled={files.full}
|
disabled={files.full}
|
||||||
onFiles={files.add}
|
onFiles={files.add}
|
||||||
/>
|
/>
|
||||||
<Suspense fallback={<ComposerInputFallback placeholder={placeholder} />}>
|
<ComposerEditor editor={editor} invalid={Boolean(error)} title={error} />
|
||||||
<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}
|
||||||
|
|||||||
@@ -17,6 +17,10 @@ import NewSessionButton from "./new-session-button";
|
|||||||
import HeaderIconButton from "@/src/components/general/header-icon-button";
|
import HeaderIconButton from "@/src/components/general/header-icon-button";
|
||||||
import RailDrawerButton from "@/src/layouts/bots/(partials)/rail-drawer-button";
|
import RailDrawerButton from "@/src/layouts/bots/(partials)/rail-drawer-button";
|
||||||
import HeaderMetaItem from "./header-meta-item";
|
import HeaderMetaItem from "./header-meta-item";
|
||||||
|
import Span from "@moduletrace/twui/layout/Span";
|
||||||
|
import Img from "@moduletrace/twui/layout/Img";
|
||||||
|
import grabMediaURL from "@/src/functions/backend/media/grab-media-url";
|
||||||
|
import WorkspaceAvatar from "@/src/components/general/workspace-avatar";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
bot: BotWithLastMessage;
|
bot: BotWithLastMessage;
|
||||||
@@ -34,7 +38,11 @@ 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 }: Props) {
|
export default function ConversationHeader({
|
||||||
|
bot,
|
||||||
|
workspace,
|
||||||
|
has_other_unread,
|
||||||
|
}: Props) {
|
||||||
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, setModel] = useState(pageProps?.bot_text_model);
|
||||||
@@ -52,9 +60,27 @@ export default function ConversationHeader({ bot, workspace, has_other_unread }:
|
|||||||
<H1 className="text-lg! leading-6! tracking-normal! font-bold text-ink truncate w-full">
|
<H1 className="text-lg! leading-6! tracking-normal! font-bold text-ink truncate w-full">
|
||||||
{bot.name}
|
{bot.name}
|
||||||
</H1>
|
</H1>
|
||||||
|
<Row className="flex md:hidden min-w-0 w-full gap-x-3! gap-y-0! flex-nowrap">
|
||||||
|
{workspace?.id ? (
|
||||||
|
<Row className="gap-2! mb-0.5">
|
||||||
|
<WorkspaceAvatar
|
||||||
|
image_media_id={workspace?.image_media_id}
|
||||||
|
size="xs"
|
||||||
|
/>
|
||||||
|
<Span size="smaller">
|
||||||
|
{workspace.name || "Workspace"}
|
||||||
|
</Span>
|
||||||
|
</Row>
|
||||||
|
) : null}
|
||||||
|
</Row>
|
||||||
|
|
||||||
<Row className="hidden md:flex min-w-0 w-full gap-x-3! gap-y-0! flex-nowrap">
|
<Row className="hidden md:flex min-w-0 w-full gap-x-3! gap-y-0! flex-nowrap">
|
||||||
{workspace?.id ? (
|
{workspace?.id ? (
|
||||||
<HeaderMetaItem icon={Layers} label="Workspace" text={workspace.name || "Workspace"} />
|
<HeaderMetaItem
|
||||||
|
icon={Layers}
|
||||||
|
label="Workspace"
|
||||||
|
text={workspace.name || "Workspace"}
|
||||||
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
<HeaderMetaItem
|
<HeaderMetaItem
|
||||||
icon={CpuAi}
|
icon={CpuAi}
|
||||||
@@ -80,17 +106,36 @@ export default function ConversationHeader({ bot, workspace, has_other_unread }:
|
|||||||
workspace_id={workspace?.id || null}
|
workspace_id={workspace?.id || null}
|
||||||
has_other_unread={has_other_unread}
|
has_other_unread={has_other_unread}
|
||||||
/>
|
/>
|
||||||
<NewSessionButton bot_id={Number(bot.id)} workspace_id={workspace?.id || null} />
|
<NewSessionButton
|
||||||
<BotComputersModal bot={bot} workspace_name={workspace?.id ? workspace.name || "This workspace" : null} />
|
bot_id={Number(bot.id)}
|
||||||
|
workspace_id={workspace?.id || null}
|
||||||
|
/>
|
||||||
|
<BotComputersModal
|
||||||
|
bot={bot}
|
||||||
|
workspace_name={
|
||||||
|
workspace?.id
|
||||||
|
? workspace.name || "This workspace"
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
/>
|
||||||
<span className="hidden md:inline-flex">
|
<span className="hidden md:inline-flex">
|
||||||
<BotModelsModal
|
<BotModelsModal
|
||||||
key={bot.id}
|
key={bot.id}
|
||||||
bot={bot}
|
bot={bot}
|
||||||
workspace={workspace}
|
workspace={workspace}
|
||||||
onSaved={(harnesses) => {
|
onSaved={(harnesses) => {
|
||||||
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,
|
||||||
|
);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -6,6 +6,12 @@ import linkComputerRefs from "@/src/utils/link-computer-refs";
|
|||||||
import { UserMarkdownComponents } from "../(data)/user-markdown-components";
|
import { UserMarkdownComponents } from "../(data)/user-markdown-components";
|
||||||
import { QuotableAttribute } from "../(hooks)/use-message-selection";
|
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
|
* 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
|
* (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]: "" }}
|
{...{ [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"
|
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"
|
||||||
>
|
>
|
||||||
<Markdown remarkPlugins={[remarkGfm, remarkBreaks]} components={UserMarkdownComponents}>
|
<Markdown remarkPlugins={RemarkPlugins} components={UserMarkdownComponents}>
|
||||||
{linkComputerRefs({ text })}
|
{linkComputerRefs({ text })}
|
||||||
</Markdown>
|
</Markdown>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
@@ -45,9 +45,13 @@ export default function Conversation() {
|
|||||||
const [messages, setMessages] = useState<BotMessageWithAttachments[]>(
|
const [messages, setMessages] = useState<BotMessageWithAttachments[]>(
|
||||||
pageProps?.bot_messages || [],
|
pageProps?.bot_messages || [],
|
||||||
);
|
);
|
||||||
const [session_id, setSessionId] = useState<number | null>(pageProps?.bot_session?.id || null);
|
const [session_id, setSessionId] = useState<number | null>(
|
||||||
|
pageProps?.bot_session?.id || null,
|
||||||
|
);
|
||||||
const [has_other_unread, setHasOtherUnread] = useState(false);
|
const [has_other_unread, setHasOtherUnread] = useState(false);
|
||||||
const [reply_to, setReplyTo] = useState<BotMessageWithAttachments | null>(null);
|
const [reply_to, setReplyTo] = useState<BotMessageWithAttachments | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A quote picked in one session doesn't follow the thread to another.
|
* 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 || [],
|
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({
|
const progress = useTaskProgress({
|
||||||
bot_id: bot?.id || 0,
|
bot_id: bot?.id || 0,
|
||||||
@@ -100,7 +106,10 @@ export default function Conversation() {
|
|||||||
* moves the thread onto it.
|
* moves the thread onto it.
|
||||||
*/
|
*/
|
||||||
const startsNewSession = (message: BotMessageWithAttachments) =>
|
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({
|
useServerEvent({
|
||||||
event: "server:bot-message",
|
event: "server:bot-message",
|
||||||
@@ -140,10 +149,15 @@ export default function Conversation() {
|
|||||||
* A reaction toggled here or in another tab: the pushed list replaces
|
* A reaction toggled here or in another tab: the pushed list replaces
|
||||||
* the message's own.
|
* the message's own.
|
||||||
*/
|
*/
|
||||||
const applyReactions = ({ message_id, reactions }: BotMessageReactionData) => {
|
const applyReactions = ({
|
||||||
|
message_id,
|
||||||
|
reactions,
|
||||||
|
}: BotMessageReactionData) => {
|
||||||
setMessages((prev) =>
|
setMessages((prev) =>
|
||||||
prev.map((message) =>
|
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(
|
const message_actions = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
onReply: ({ message }: { message: BotMessageWithAttachments }) => setReplyTo(message),
|
onReply: ({ message }: { message: BotMessageWithAttachments }) =>
|
||||||
|
setReplyTo(message),
|
||||||
onReact: reactToMessage,
|
onReact: reactToMessage,
|
||||||
}),
|
}),
|
||||||
[],
|
[],
|
||||||
@@ -181,7 +196,9 @@ 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 value={message_actions}>
|
<BotMessageActionsContext.Provider
|
||||||
|
value={message_actions}
|
||||||
|
>
|
||||||
<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}
|
||||||
@@ -194,7 +211,9 @@ export default function Conversation() {
|
|||||||
progress={progress.text}
|
progress={progress.text}
|
||||||
reply_progress={reply_progress}
|
reply_progress={reply_progress}
|
||||||
has_task={Boolean(progress.task_id)}
|
has_task={Boolean(progress.task_id)}
|
||||||
onQuote={({ text }) => composer.current?.insertQuote({ text })}
|
onQuote={({ text }) =>
|
||||||
|
composer.current?.insertQuote({ text })
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<Composer
|
<Composer
|
||||||
handle_ref={composer}
|
handle_ref={composer}
|
||||||
@@ -207,8 +226,13 @@ export default function Conversation() {
|
|||||||
onSent={({ message }) => {
|
onSent={({ message }) => {
|
||||||
setReplyTo(null);
|
setReplyTo(null);
|
||||||
|
|
||||||
if (startsNewSession(message) && message.session_id != session_id) {
|
if (
|
||||||
setSessionId(Number(message.session_id));
|
startsNewSession(message) &&
|
||||||
|
message.session_id != session_id
|
||||||
|
) {
|
||||||
|
setSessionId(
|
||||||
|
Number(message.session_id),
|
||||||
|
);
|
||||||
setMessages([message]);
|
setMessages([message]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {}
|
|
||||||
}
|
|
||||||
@@ -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" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 }];
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -21,6 +21,11 @@ type Props = {
|
|||||||
* Gets the fresh harnesses after each save.
|
* Gets the fresh harnesses after each save.
|
||||||
*/
|
*/
|
||||||
onSaved?: (harnesses: HarnessOverview[]) => void;
|
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
|
* runs on the defaults, so it saves the defaults themselves, which only a
|
||||||
* super admin can.
|
* 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);
|
const [harnesses, setHarnesses] = useState(initial);
|
||||||
/**
|
/**
|
||||||
* Remounts the form when the data changes under it.
|
* Remounts the form when the data changes under it.
|
||||||
@@ -49,6 +60,7 @@ export default function BotHarnessForm({ bot, workspace_id, harnesses: initial,
|
|||||||
key={version}
|
key={version}
|
||||||
harnesses={harnesses}
|
harnesses={harnesses}
|
||||||
notice={notice}
|
notice={notice}
|
||||||
|
onDirtyChange={onDirtyChange}
|
||||||
save={async ({ settings }) => {
|
save={async ({ settings }) => {
|
||||||
if (workspace_id) {
|
if (workspace_id) {
|
||||||
const res = await saveWorkspaceHarnessSettings({
|
const res = await saveWorkspaceHarnessSettings({
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import fetchApi from "@moduletrace/twui/utils/fetch/fetchApi";
|
import fetchApi from "@moduletrace/twui/utils/fetch/fetchApi";
|
||||||
import type { APIResponseObject } from "@moduletrace/bunext/types";
|
import type { APIResponseObject } from "@moduletrace/bunext/types";
|
||||||
import type { HarnessOverviewPayload } from "@/src/types";
|
import type { HarnessOverviewPayload } from "@/src/types";
|
||||||
|
import { clearHarnessCache } from "@/src/utils/harness-cache";
|
||||||
|
|
||||||
type Params = {
|
type Params = {
|
||||||
/**
|
/**
|
||||||
@@ -15,11 +16,15 @@ type Params = {
|
|||||||
* data in `singleRes`, so the page updates in place.
|
* data in `singleRes`, so the page updates in place.
|
||||||
*/
|
*/
|
||||||
export default async function postHarnessApi({ path, body }: Params) {
|
export default async function postHarnessApi({ path, body }: Params) {
|
||||||
return await fetchApi<
|
const res = await fetchApi<
|
||||||
Record<string, unknown>,
|
Record<string, unknown>,
|
||||||
APIResponseObject<HarnessOverviewPayload>
|
APIResponseObject<HarnessOverviewPayload>
|
||||||
>(`/api/admin/harness/${path}`, {
|
>(`/api/admin/harness/${path}`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body,
|
body,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (res.success) clearHarnessCache();
|
||||||
|
|
||||||
|
return res;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import Form from "@moduletrace/twui/form/Form";
|
import Form from "@moduletrace/twui/form/Form";
|
||||||
import useFormInit from "@/src/hooks/use-form-init";
|
import useFormInit from "@/src/hooks/use-form-init";
|
||||||
import type { HarnessOverview, HarnessSettingsFormObject } from "@/src/types";
|
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.
|
* The last save's message, kept by the page across the remount.
|
||||||
*/
|
*/
|
||||||
notice?: string;
|
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,
|
harnesses,
|
||||||
save,
|
save,
|
||||||
notice: saved_notice,
|
notice: saved_notice,
|
||||||
|
onDirtyChange,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const saved = toHarnessForm({ harnesses });
|
const saved = toHarnessForm({ harnesses });
|
||||||
/**
|
/**
|
||||||
@@ -47,6 +53,10 @@ export default function HarnessSettingsForm({
|
|||||||
const changed = changedHarnessSettings({ form, saved });
|
const changed = changedHarnessSettings({ form, saved });
|
||||||
const dirty = changed.length > 0;
|
const dirty = changed.length > 0;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onDirtyChange?.(dirty);
|
||||||
|
}, [dirty, onDirtyChange]);
|
||||||
|
|
||||||
const discard = () => {
|
const discard = () => {
|
||||||
setForm(saved);
|
setForm(saved);
|
||||||
setStatus({});
|
setStatus({});
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import userAuth from "@/src/functions/backend/auth/user-auth";
|
import userAuth from "@/src/functions/backend/auth/user-auth";
|
||||||
import grabUserConversation from "@/src/functions/backend/db/bot-sessions/grab-user-conversation";
|
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 { 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 AppError from "@/src/utils/app-error";
|
||||||
import parseWithSchema from "@/src/utils/parse-with-schema";
|
import parseWithSchema from "@/src/utils/parse-with-schema";
|
||||||
import canManageCrew from "@/src/utils/rbac/can-manage-crew";
|
import canManageCrew from "@/src/utils/rbac/can-manage-crew";
|
||||||
@@ -14,12 +14,13 @@ import type {
|
|||||||
} from "@moduletrace/bunext/types";
|
} 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
|
* 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<
|
export const handler: BunextAPIRouteHandler<
|
||||||
APIResponseObject<BotHarnessOverviewPayload>
|
APIResponseObject<BotHarnessSettingsPayload>
|
||||||
> = async ({ req, body }) => {
|
> = async ({ req, body }) => {
|
||||||
try {
|
try {
|
||||||
if (req.method != "POST") {
|
if (req.method != "POST") {
|
||||||
@@ -50,7 +51,7 @@ export const handler: BunextAPIRouteHandler<
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
singleRes: await grabConversationHarnessOverview({
|
singleRes: await grabConversationHarnessSettings({
|
||||||
bot: conversation.bot,
|
bot: conversation.bot,
|
||||||
workspace_id: scope,
|
workspace_id: scope,
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -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<HarnessCatalogPayload>
|
||||||
|
> = 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 }) };
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -10,9 +10,12 @@ import Paper from "@moduletrace/twui/elements/Paper";
|
|||||||
import Container from "@moduletrace/twui/layout/Container";
|
import Container from "@moduletrace/twui/layout/Container";
|
||||||
import Center from "@moduletrace/twui/layout/Center";
|
import Center from "@moduletrace/twui/layout/Center";
|
||||||
import { SiteData } from "@/src/data/site-data";
|
import { SiteData } from "@/src/data/site-data";
|
||||||
|
import { clearHarnessCache } from "@/src/utils/harness-cache";
|
||||||
|
|
||||||
export default function LogoutPage() {
|
export default function LogoutPage() {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
clearHarnessCache();
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
window.location.pathname = "/auth/login";
|
window.location.pathname = "/auth/login";
|
||||||
}, 1000);
|
}, 1000);
|
||||||
|
|||||||
@@ -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<string, number>();
|
||||||
|
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<string>();
|
||||||
|
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<string>();
|
||||||
|
|
||||||
|
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`;
|
||||||
|
}
|
||||||
@@ -340,6 +340,55 @@ export type HarnessOverviewPayload = {
|
|||||||
harnesses: HarnessOverview[];
|
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
|
* An env as the Environment settings page sees it. `value` is sent only
|
||||||
* for non-secret envs; secrets are replaced, never read back.
|
* for non-secret envs; secrets are replaced, never read back.
|
||||||
|
|||||||
@@ -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<string, string>();
|
||||||
|
|
||||||
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<T> = {
|
||||||
|
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<T>(key: string): CacheEnvelope<T> | 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<T>(key: string, data: T) {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(
|
||||||
|
key,
|
||||||
|
JSON.stringify({ v: CacheVersion, at: Date.now(), data } satisfies CacheEnvelope<T>),
|
||||||
|
);
|
||||||
|
} 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<HarnessCatalogPayload>(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<BotHarnessSettingsPayload>(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 {}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user