Tidy the bot chat: icon header controls, status in the rail, narrower thread

Drop the working-on panel. Rail rows show the bot's status line first.
The header's computer and model controls are ghost icons opening modals
that explain them, and the bot's text model, provider and computer show
under its name. The thread and composer are held to 760px.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-19 05:02:28 +01:00
co-authored by Claude Opus 5.5
parent 7b580d782e
commit 02ed47dd86
21 changed files with 263 additions and 379 deletions
+6 -6
View File
@@ -85,19 +85,19 @@ Inspect live data with `bun llm-code-assistant/query-db.ts "<sql>"` - a read-onl
- **Tables:** `bots` (per user; `kind` worker|coordinator, `hue`, `role`, `face`, `status_line`, `system_prompt` (the bot's core instructions, markdown), `unread`), `bot_messages`, `bot_tasks`, `bot_task_steps`. Enums in `src/dict/bot-dict.ts`. - **Tables:** `bots` (per user; `kind` worker|coordinator, `hue`, `role`, `face`, `status_line`, `system_prompt` (the bot's core instructions, markdown), `unread`), `bot_messages`, `bot_tasks`, `bot_task_steps`. Enums in `src/dict/bot-dict.ts`.
- **Coordinator:** every user has exactly one `kind: coordinator` bot that coordinates the crew and delegates to the workers. It is created with the account (signup, `bun run seed`) and, as a fallback, by `/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:** every user has exactly one `kind: coordinator` bot that coordinates the crew and delegates to the workers. It is created with the account (signup, `bun run seed`) and, as a fallback, by `/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 to its chat (`/admin/bots/1` on a fresh install); `/admin/bots/[bot_id]` renders conversation + working-on panel; `/admin/bots/[bot_id]/models` is a worker bot's own model settings (the coordinator's redirects to its chat). Both 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 to its chat (`/admin/bots/1` on a fresh install); `/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 (the coordinator's redirects to its chat). Both 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`, `mark-bot-read`, `insert-user-message`). `api/admin/bots/send-message.ts` posts a user message to a bot owned by the session user (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 unread for a message that arrives in the open conversation; `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`, `mark-bot-read`, `insert-user-message`). `api/admin/bots/send-message.ts` posts a user message to a bot owned by the session user (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 unread for a message that arrives in the open conversation; `save-harness-settings.ts` saves a worker bot's models.
- **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). User messages stay plain text. - **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). User messages stay plain text.
- **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 a reply runs (face `working`, status `BotReplyJobTitle`) or waits for a model (`BotWaitingStatusLine`), `BotThinkingRow` sits under the last message. 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 a reply runs (face `working`, status `BotReplyJobTitle`) or waits for a model (`BotWaitingStatusLine`), `BotThinkingRow` sits under the last message. Live notices come from the process that ran the job at once, and from every other process through the live relay.
- **Avatars:** `BotStone` (`src/components/general/bot-stone.tsx`) renders the stone inline from `src/data/stone-geometry.ts`, hue as a fill token, face (`idle|working|done|asleep|needs-you`) drawn as eyes. The coordinator is hue-less (ink body, paper eyes; inverts in dark). Never put a stone in a circle/badge or add outline/shadow. Wordmark is vector (`Wordmark`, `src/data/wordmark-paths.ts`), never live text. - **Avatars:** `BotStone` (`src/components/general/bot-stone.tsx`) renders the stone inline from `src/data/stone-geometry.ts`, hue as a fill token, face (`idle|working|done|asleep|needs-you`) drawn as eyes. The coordinator is hue-less (ink body, paper eyes; inverts in dark). Never put a stone in a circle/badge or add outline/shadow. Wordmark is vector (`Wordmark`, `src/data/wordmark-paths.ts`), never live text.
- **Copy helpers:** `src/utils/crew-status.ts` (`3 working · 1 needs you`, "Three bots are working. One needs you."), `src/utils/relative-time.ts` (relative timestamps only). - **Copy helpers:** `src/utils/crew-status.ts` (`3 working · 1 needs you`, "Three bots are working. One needs you."), `src/utils/relative-time.ts` (relative timestamps only).
- **Prompt and memory (self-learning):** a bot has `system_prompt` (core instructions) and a separate `memory` (markdown, what it must always remember). The bot rewrites both as it adapts to the user (`update-bot-system-prompt`, `update-bot-memory` in `src/functions/backend/db/bots/`; whole-text replace, capped at `AppData.PromptMaxLength`). `grab-bot-prompt` builds the full prompt via `composeBotPrompt` (`src/utils/compose-bot-prompt.ts`): core instructions, then each non-archived workspace's `system_prompt` under `# Workspace: <name>`, then `# Memory`. - **Prompt and memory (self-learning):** a bot has `system_prompt` (core instructions) and a separate `memory` (markdown, what it must always remember). The bot rewrites both as it adapts to the user (`update-bot-system-prompt`, `update-bot-memory` in `src/functions/backend/db/bots/`; whole-text replace, capped at `AppData.PromptMaxLength`). `grab-bot-prompt` builds the full prompt via `composeBotPrompt` (`src/utils/compose-bot-prompt.ts`): core instructions, then each non-archived workspace's `system_prompt` under `# Workspace: <name>`, then `# Memory`.
- **Queue:** every bot job goes through `bots_queue`, with `kind` (`BotJobKinds`): `reply` (answers the user in chat, no task row) or `task` (long-running work, typically on a computer). Long-running work goes through `bots_queue`, a bun-sqlite `isQueue` table worked with the native `BunSQLite.queue` API (`status` pending|processing|completed|failed, `attempts`, `available_at`, `locked_at`/`locked_by`, `last_error`; options in `AppData.BotQueue*`). Not every message needs a job: the bot replies right away that it's on it and `enqueueBotJob` queues the brief (`instructions`, `params`, optional `message_id`/`computer_id`, the computer checked against the bot's access; a `bot_tasks` row is created for the working-on panel unless `task_id` is given). DB helpers in `src/functions/backend/db/bots-queue/`: `claimBotJobs`, `updateBotJob` (renews the lock, saves `progress`/`checkpoint`; false once lost or cancelled), `completeBotJob` (result message, task done, face done/needs-you), `failBotJob` (backoff retry, or `fatal`; out of attempts posts a needs-you message via `notifyBotJobFailed`), `cancelBotJob` (failed with `BotJobCancelledError`, `src/dict/bot-queue-dict.ts`), `releaseWorkerBotJobs`. Every model turn of a job is a step in `bots_queue_updates` (`kind` tool|note, one-line `text`, JSON `metadata`; tool names, never arguments; `describe-job-step.ts`), written through the job context's `report` (the text loop's `onStep`), which also sets `progress` and pushes `server:bot-job-update` (`BotJobUpdateData`). Steps are deleted with their job when it's pruned. The working-on panel shows the active task's latest `AppData.BotJobUpdatesShown` steps (`grabBotJobUpdates`, on `BotTaskWithSteps.updates`) when the task has no planned `bot_task_steps`, the newest as in progress. - **Queue:** every bot job goes through `bots_queue`, with `kind` (`BotJobKinds`): `reply` (answers the user in chat, no task row) or `task` (long-running work, typically on a computer). Long-running work goes through `bots_queue`, a bun-sqlite `isQueue` table worked with the native `BunSQLite.queue` API (`status` pending|processing|completed|failed, `attempts`, `available_at`, `locked_at`/`locked_by`, `last_error`; options in `AppData.BotQueue*`). Not every message needs a job: the bot replies right away that it's on it and `enqueueBotJob` queues the brief (`instructions`, `params`, optional `message_id`/`computer_id`, the computer checked against the bot's access; a `bot_tasks` row is created unless `task_id` is given). DB helpers in `src/functions/backend/db/bots-queue/`: `claimBotJobs`, `updateBotJob` (renews the lock, saves `progress`/`checkpoint`; false once lost or cancelled), `completeBotJob` (result message, task done, face done/needs-you), `failBotJob` (backoff retry, or `fatal`; out of attempts posts a needs-you message via `notifyBotJobFailed`), `cancelBotJob` (failed with `BotJobCancelledError`, `src/dict/bot-queue-dict.ts`), `releaseWorkerBotJobs`. Every model turn of a job is a step in `bots_queue_updates` (`kind` tool|note, one-line `text`, JSON `metadata`; tool names, never arguments; `describe-job-step.ts`), written through the job context's `report` (the text loop's `onStep`), which also sets `progress` and pushes `server:bot-job-update` (`BotJobUpdateData`). Steps are deleted with their job when it's pruned. The active task's latest `AppData.BotJobUpdatesShown` steps ride on `BotTaskWithSteps.updates` (`grabBotJobUpdates`); nothing in the UI shows them since the working-on panel was removed.
- **Queue handler:** `src/bots-queue/index.ts`, started from `server.ts` after cron and gated the same way. It releases its own cut-off jobs on startup (stable worker `AppData.BotQueueWorker`), then polls every `BotQueuePollMs`: reports jobs bun-sqlite failed on a lock timeout (`report-lost-bot-jobs`), claims per kind (`claimBotJobs` mirrors bun-sqlite's atomic claim plus `kind`, since `queue.claim` has no filter) up to `BotQueueReplyConcurrency` replies and `BotQueueConcurrency` tasks on separate slots, and runs each with `processBotJob` (heartbeat at a quarter of the lock timeout, abort on cancel/loss, one task per computer and one reply per bot via `createKeyLock`, face `working` once it starts, outcome pushed live). The actual work is `run-bot-job.ts` (`BotJobRunner`, context in `types.ts`), which dispatches to `run-bot-reply.ts` and `run-bot-task.ts`. Throw `BotJobFatalError` for failures retrying can't fix. When every model is resting (`HarnessError.resting`, nothing was tried) the job is put back with `deferBotJob` until the first comes back, its attempt refunded and the bot's face `asleep`; other retryable harness errors retry no sooner than their `retry_at`. The `prune-bots-queue` cron deletes finished jobs after `BotQueueRetentionDays`. - **Queue handler:** `src/bots-queue/index.ts`, started from `server.ts` after cron and gated the same way. It releases its own cut-off jobs on startup (stable worker `AppData.BotQueueWorker`), then polls every `BotQueuePollMs`: reports jobs bun-sqlite failed on a lock timeout (`report-lost-bot-jobs`), claims per kind (`claimBotJobs` mirrors bun-sqlite's atomic claim plus `kind`, since `queue.claim` has no filter) up to `BotQueueReplyConcurrency` replies and `BotQueueConcurrency` tasks on separate slots, and runs each with `processBotJob` (heartbeat at a quarter of the lock timeout, abort on cancel/loss, one task per computer and one reply per bot via `createKeyLock`, face `working` once it starts, outcome pushed live). The actual work is `run-bot-job.ts` (`BotJobRunner`, context in `types.ts`), which dispatches to `run-bot-reply.ts` and `run-bot-task.ts`. Throw `BotJobFatalError` for failures retrying can't fix. When every model is resting (`HarnessError.resting`, nothing was tried) the job is put back with `deferBotJob` until the first comes back, its attempt refunded and the bot's face `asleep`; other retryable harness errors retry no sooner than their `retry_at`. The `prune-bots-queue` cron deletes finished jobs after `BotQueueRetentionDays`.
- **Live updates:** server push only. `sendToUser` (`src/websocket/(functions)/send-to-user.ts`) sends to one user's sockets; `pushBotNotice` sends `server:bot-message` (the message row, user messages too, for other tabs) and `server:bot-updated` (`BotUpdatedData`: face, status line, active task). The bots layout opens one socket (twui `useWebSocket`); components listen with `useServerEvent` (`src/hooks/`), and `useLiveBots` keeps the rail and header current. The conversation dedupes messages by id; the working-on panel takes the pushed task. Every process also runs `liveRelay` (`src/live-relay/`): every `AppData.LiveRelayPollMs` it reads new `bot_messages`, new `bots_queue_updates` and changed bots from the database and pushes them to its own sockets, so work done by another process still shows live; clients drop what they already have. `server:bot-updated` carries the bot's `computer_id` when known, which keeps the computer select in step across tabs. The rail previews a message as plain text (`src/utils/message-preview.ts`). - **Live updates:** server push only. `sendToUser` (`src/websocket/(functions)/send-to-user.ts`) sends to one user's sockets; `pushBotNotice` sends `server:bot-message` (the message row, user messages too, for other tabs) and `server:bot-updated` (`BotUpdatedData`: face, status line, active task). The bots layout opens one socket (twui `useWebSocket`); components listen with `useServerEvent` (`src/hooks/`), and `useLiveBots` keeps the rail and header current. The conversation dedupes messages by id. Every process also runs `liveRelay` (`src/live-relay/`): every `AppData.LiveRelayPollMs` it reads new `bot_messages`, new `bots_queue_updates` and changed bots from the database and pushes them to its own sockets, so work done by another process still shows live; clients drop what they already have. `server:bot-updated` carries the bot's `computer_id` when known, which keeps the header's computer in step across tabs. A rail row's subtitle is the bot's status line while it has one, else (coordinator) the crew counts while a worker is busy or needs you, else its last message as plain text (`src/utils/message-preview.ts`).
- **Workspaces:** `workspaces` (per user, `name`, `description`, `system_prompt` shared by every bot in it, `archived`), with bots assigned through `workspace_bots` and computers through `workspace_computers`. Helpers in `src/functions/backend/db/workspaces/` (`create-workspace`, `update-workspace`, `add/remove-bot-*`, `add/remove-computer-*`), all ownership-checked against the session user. - **Workspaces:** `workspaces` (per user, `name`, `description`, `system_prompt` shared by every bot in it, `archived`), with bots assigned through `workspace_bots` and computers through `workspace_computers`. Helpers in `src/functions/backend/db/workspaces/` (`create-workspace`, `update-workspace`, `add/remove-bot-*`, `add/remove-computer-*`), all ownership-checked against the session user.
- **Computers:** a computer is an AIO sandbox in its own docker container on the `AppData.DockerNetworkName` network. `containers` records every docker container the app knows about except the main app container (`name` doubles as the hostname, plus `docker_id`, `image`, `network`, `port`, `status`, `metadata`). `computers` (per user, `kind` main|sandbox, `container_id`) and `bot_computers` (direct access). Each user has one main computer, recorded with the account (signup, `bun run seed`) and as a fallback by `/admin/bots` via `ensureMainComputer` (`src/functions/backend/db/computers/ensure-main-computer.ts`, idempotent), which also records its container (`AppData.MainComputerContainerName`, created by the installer) and grants the coordinator access. That one grant can't be removed (`isLockedComputerAccess`, enforced in `remove-bot-from-computer` and the `bot_computers` CRUD handler); every other grant can. A bot's effective computers (`grab-bot-computers`) are its direct grants plus every computer assigned to a non-archived workspace it's in (the coordinator's are all of the user's computers), the one it's on first: `bots.computer_id` (not a foreign key; null or a lost grant means its main one), so `computers[0]` is where its tasks run by default. The user switches it from the chat header (`(partials)/bot-computer-select.tsx`, beside the model button) or by clicking a computer the bot named in a message: bots write `[computer:ID]` (`src/utils/computer-ref.ts`, turned into `#computer-ID` links by `link-computer-refs.ts`, rendered by `(partials)/computer-ref-chip.tsx`), both sharing `BotComputersContext` / `use-bot-computers`, saved by `api/admin/bots/set-computer.ts` (`setBotComputer`). Enums in `src/dict/computer-dict.ts`. - **Computers:** a computer is an AIO sandbox in its own docker container on the `AppData.DockerNetworkName` network. `containers` records every docker container the app knows about except the main app container (`name` doubles as the hostname, plus `docker_id`, `image`, `network`, `port`, `status`, `metadata`). `computers` (per user, `kind` main|sandbox, `container_id`) and `bot_computers` (direct access). Each user has one main computer, recorded with the account (signup, `bun run seed`) and as a fallback by `/admin/bots` via `ensureMainComputer` (`src/functions/backend/db/computers/ensure-main-computer.ts`, idempotent), which also records its container (`AppData.MainComputerContainerName`, created by the installer) and grants the coordinator access. That one grant can't be removed (`isLockedComputerAccess`, enforced in `remove-bot-from-computer` and the `bot_computers` CRUD handler); every other grant can. A bot's effective computers (`grab-bot-computers`) are its direct grants plus every computer assigned to a non-archived workspace it's in (the coordinator's are all of the user's computers), the one it's on first: `bots.computer_id` (not a foreign key; null or a lost grant means its main one), so `computers[0]` is where its tasks run by default. The user switches it from the chat header (`(partials)/bot-computers-modal.tsx`, a ghost icon beside the model icon; the header names the current computer and the text model and provider under the bot's name) or by clicking a computer the bot named in a message: bots write `[computer:ID]` (`src/utils/computer-ref.ts`, turned into `#computer-ID` links by `link-computer-refs.ts`, rendered by `(partials)/computer-ref-chip.tsx`), both sharing `BotComputersContext` / `use-bot-computers`, saved by `api/admin/bots/set-computer.ts` (`setBotComputer`). Enums in `src/dict/computer-dict.ts`.
- **Not built yet:** add-bot flow, routines, help, task steps (the working-on panel shows the task title; nothing writes `bot_task_steps` yet). - **Not built yet:** add-bot flow, routines, help, task steps (nothing writes `bot_task_steps` yet).
### Envs ### Envs
@@ -116,7 +116,7 @@ Envs are set in Settings, under Environment (super admin only), not in `.env`. T
- **Catalog:** `harness_models` per provider (modalities, tools support, voices, Artificial Analysis scores, `free`, `available`). The `sync-harness-models` cron (hourly, on start) refreshes every enabled provider (`syncAllHarnessModels`); unlisted models are kept but marked unavailable. OpenRouter lists every modality with capabilities; other providers list ids, so capabilities are guessed (`guess-model-modalities`). Free on OpenRouter means `:free` or `openrouter/free`, never zero token prices. - **Catalog:** `harness_models` per provider (modalities, tools support, voices, Artificial Analysis scores, `free`, `available`). The `sync-harness-models` cron (hourly, on start) refreshes every enabled provider (`syncAllHarnessModels`); unlisted models are kept but marked unavailable. OpenRouter lists every modality with capabilities; other providers list ids, so capabilities are guessed (`guess-model-modalities`). Free on OpenRouter means `:free` or `openrouter/free`, never zero token prices.
- **Automatic picks** (`rank-auto-models`): free models that can do the job (on the setting's provider when it has one; a provider without free models needs a picked model) (`model-serves-harness`: text needs tools), by vendor `AutoModelVendorPreference` (DeepSeek, Qwen, NVIDIA, Liquid), then other vendors that look competent (`AppData.HarnessAutoMin*`), then routers; within a vendor by intelligence index, then full-size before small, newest first. Transcription puts real speech-to-text models first. - **Automatic picks** (`rank-auto-models`): free models that can do the job (on the setting's provider when it has one; a provider without free models needs a picked model) (`model-serves-harness`: text needs tools), by vendor `AutoModelVendorPreference` (DeepSeek, Qwen, NVIDIA, Liquid), then other vendors that look competent (`AppData.HarnessAutoMin*`), then routers; within a vendor by intelligence index, then full-size before small, newest first. Transcription puts real speech-to-text models first.
- **Calling:** `resolveHarnessCandidates` (pinned, then automatic) -> `withCandidates` tries them in order with process-wide cooldowns (`resolve/harness-cooldowns.ts`): rate limits and outages rest the model (until `Retry-After` when given), 401/402 rest the whole provider, 403/404 rest the model for an hour. Errors are `HarnessError` (`code` provider|unavailable|aborted, `retryable`), classified in `to-harness-error.ts`. Entry points: `text/generate-text.ts` (one turn), `text/run-text-loop.ts` (tool loop, sticks to one model, wraps up after `HarnessMaxToolSteps`, trims the oldest tool output before each turn to fit the model's context, `fit-transcript-to-context.ts`), `media/read-image.ts`, `media/read-media.ts` (documents locally via `fileToText`, images and audio through their harnesses), `audio/transcribe-audio.ts`, `audio/speak-text.ts`, `image/generate-image.ts`. A text model that can't take an image or audio part gets it read by the vision/transcription harness first (`prepare-messages-for-model.ts`). - **Calling:** `resolveHarnessCandidates` (pinned, then automatic) -> `withCandidates` tries them in order with process-wide cooldowns (`resolve/harness-cooldowns.ts`): rate limits and outages rest the model (until `Retry-After` when given), 401/402 rest the whole provider, 403/404 rest the model for an hour. Errors are `HarnessError` (`code` provider|unavailable|aborted, `retryable`), classified in `to-harness-error.ts`. Entry points: `text/generate-text.ts` (one turn), `text/run-text-loop.ts` (tool loop, sticks to one model, wraps up after `HarnessMaxToolSteps`, trims the oldest tool output before each turn to fit the model's context, `fit-transcript-to-context.ts`), `media/read-image.ts`, `media/read-media.ts` (documents locally via `fileToText`, images and audio through their harnesses), `audio/transcribe-audio.ts`, `audio/speak-text.ts`, `image/generate-image.ts`. A text model that can't take an image or audio part gets it read by the vision/transcription harness first (`prepare-messages-for-model.ts`).
- **Per-bot models:** a worker bot inherits the defaults per harness unless it has a `bot_harness_settings` row (same fields as `harness_settings`, unique per bot and harness). `grabHarnessSetting({ harness, bot_id })` returns the bot's row (`source: "bot"`) or the default; the coordinator always gets the default. Every entry point takes `bot_id` and passes it on (text loop -> media reading/transcription), so a bot's run uses its own models throughout. Saved by `saveBotHarnessSetting` (`inherit` deletes the row); the page data is `grabBotHarnessOverview`. A bot's chat header has a model button (the text model it runs on) that opens a modal with every harness (`(partials)/bot-models-modal.tsx`); the modal and `/admin/bots/[bot_id]/models` both use `(partials)/bot-harness-form.tsx`. On the coordinator the modal edits the defaults themselves (`api/admin/harness/save-settings`, the same settings as Settings, under Models, without the providers): super admins only, the button is read only for everyone else. Every one of these wraps `settings/models/(partials)/harness-settings-form.tsx`: per harness a provider select first (`harness-provider-select.tsx`: any provider, one provider, or on a bot "Same as the default"), then the model select narrowed to it. Removing a provider (`deleteHarnessProvider`) puts default and bot settings that picked it or its models back on any provider (`save` prop; "Same as the default" shows when the overview item has `default`). - **Per-bot models:** a worker bot inherits the defaults per harness unless it has a `bot_harness_settings` row (same fields as `harness_settings`, unique per bot and harness). `grabHarnessSetting({ harness, bot_id })` returns the bot's row (`source: "bot"`) or the default; the coordinator always gets the default. Every entry point takes `bot_id` and passes it on (text loop -> media reading/transcription), so a bot's run uses its own models throughout. Saved by `saveBotHarnessSetting` (`inherit` deletes the row); the page data is `grabBotHarnessOverview`. A bot's chat header has a ghost model icon (`(partials)/header-icon-button.tsx`; the text model and provider show under the bot's name) that opens a modal with every harness (`(partials)/bot-models-modal.tsx`); the modal and `/admin/bots/[bot_id]/models` both use `(partials)/bot-harness-form.tsx`. On the coordinator the modal edits the defaults themselves (`api/admin/harness/save-settings`, the same settings as Settings, under Models, without the providers): super admins only, the icon is disabled for everyone else. Every one of these wraps `settings/models/(partials)/harness-settings-form.tsx`: per harness a provider select first (`harness-provider-select.tsx`: any provider, one provider, or on a bot "Same as the default"), then the model select narrowed to it. Removing a provider (`deleteHarnessProvider`) puts default and bot settings that picked it or its models back on any provider (`save` prop; "Same as the default" shows when the overview item has `default`).
- **Tools:** `HarnessTool` (built per run with its bot bound): own tools validate with a zod `schema`; tools described elsewhere pass a JSON schema as `parameters` and validate themselves. `run-tool-call.ts` reports bad calls and failures back to the model as `Error: ...`. Bot tools in `src/harness/tools/bot/`: `update_memory`, `update_instructions` (`grabBotTools`, every run), `start_task` and, for the coordinator with workers, `delegate_task` (`grabBotChatTools`, chat replies only; the job's `requested_by_bot_id` is the coordinator, and `relayJobToRequester` posts the worker's result or failure in its conversation, pushed as `relay` by `pushBotNotice`), plus for the coordinator `assign_computer` (grant or revoke a worker's direct access, `add/remove-bot-to/from-computer`; access through a workspace stays and the result says so). `search_computers` (`src/harness/tools/computer/search-computers-tool.ts`, chat replies and tasks) runs one read-only command on all of a bot's computers at once over the sandbox REST API (`run-computer-command.ts`), each output under its `[computer:ID]`, secrets redacted, capped by `AppData.ComputerSearch*`. - **Tools:** `HarnessTool` (built per run with its bot bound): own tools validate with a zod `schema`; tools described elsewhere pass a JSON schema as `parameters` and validate themselves. `run-tool-call.ts` reports bad calls and failures back to the model as `Error: ...`. Bot tools in `src/harness/tools/bot/`: `update_memory`, `update_instructions` (`grabBotTools`, every run), `start_task` and, for the coordinator with workers, `delegate_task` (`grabBotChatTools`, chat replies only; the job's `requested_by_bot_id` is the coordinator, and `relayJobToRequester` posts the worker's result or failure in its conversation, pushed as `relay` by `pushBotNotice`), plus for the coordinator `assign_computer` (grant or revoke a worker's direct access, `add/remove-bot-to/from-computer`; access through a workspace stays and the result says so). `search_computers` (`src/harness/tools/computer/search-computers-tool.ts`, chat replies and tasks) runs one read-only command on all of a bot's computers at once over the sandbox REST API (`run-computer-command.ts`), each output under its `[computer:ID]`, secrets redacted, capped by `AppData.ComputerSearch*`.
- **MCP:** `src/harness/mcp/`: `connectMcpServer` (Streamable HTTP, SSE fallback), `grabMcpTools` (each server tool becomes a `HarnessTool` with its JSON schema), `mcpResultToText` (text as is, images read by the vision harness, `isError` thrown). Computers are reached this way: `openComputerTools` (`src/harness/tools/computer/`) connects to the computer's AIO sandbox at `grabComputerMcpUrl` (container `metadata.mcp_url`, else the `MAIN_COMPUTER_URL` app setting for the main computer, for a dev server outside docker, e.g. `http://localhost:8170`, else `http://<hostname>:<port>/mcp`), leaves out `ComputerMcpExcludedTools`, and sets the computer's envs (see Envs). - **MCP:** `src/harness/mcp/`: `connectMcpServer` (Streamable HTTP, SSE fallback), `grabMcpTools` (each server tool becomes a `HarnessTool` with its JSON schema), `mcpResultToText` (text as is, images read by the vision harness, `isError` thrown). Computers are reached this way: `openComputerTools` (`src/harness/tools/computer/`) connects to the computer's AIO sandbox at `grabComputerMcpUrl` (container `metadata.mcp_url`, else the `MAIN_COMPUTER_URL` app setting for the main computer, for a dev server outside docker, e.g. `http://localhost:8170`, else `http://<hostname>:<port>/mcp`), leaves out `ComputerMcpExcludedTools`, and sets the computer's envs (see Envs).
- **Replies and tasks:** `run-bot-reply.ts` answers from the last `BotReplyHistoryMessages` messages (`botMessagesToHarness`) with `ChatReplyNote`, the bot's computers (`compose-computers-note.ts`: what they are and that they're reached through `start_task`) and the chat tools, at most `BotReplyMaxToolSteps` steps, and finishes silently if the bot already had the last word (`enqueueBotReply` reuses a pending reply, so a burst of messages gets one). `run-bot-task.ts` runs the brief (`compose-job-brief.ts`) with `TaskRunNote`, the bot tools and the job's computer tools, closing the MCP connection after. Retryable harness errors go back to the queue's backoff, the rest are `BotJobFatalError`. - **Replies and tasks:** `run-bot-reply.ts` answers from the last `BotReplyHistoryMessages` messages (`botMessagesToHarness`) with `ChatReplyNote`, the bot's computers (`compose-computers-note.ts`: what they are and that they're reached through `start_task`) and the chat tools, at most `BotReplyMaxToolSteps` steps, and finishes silently if the bot already had the last word (`enqueueBotReply` reuses a pending reply, so a burst of messages gets one). `run-bot-task.ts` runs the brief (`compose-job-brief.ts`) with `TaskRunNote`, the bot tools and the job's computer tools, closing the MCP connection after. Retryable harness errors go back to the queue's backoff, the rest are `BotJobFatalError`.
+8 -5
View File
@@ -14,21 +14,24 @@ type Props = {
bot: BotWithLastMessage; bot: BotWithLastMessage;
selected?: boolean; selected?: boolean;
/** /**
* Replaces the last-message snippet (the coordinator shows crew status). * Shown when the bot has no status line, before the last-message
* snippet (the coordinator's crew counts).
*/ */
subtitle?: string; subtitle?: string;
}; };
/** /**
* 48px rail row. Status is the face: no badges, spinners or state dots. * 48px rail row. Status is the face and the subtitle (the bot's status
* line while it has one): no badges, spinners or state dots.
* Selected is ink at 5%, never a hue. * Selected is ink at 5%, never a hue.
*/ */
export default function BotRow({ bot, selected, subtitle }: Props) { export default function BotRow({ bot, selected, subtitle }: Props) {
const hue = (bot.hue || null) as BotHue | null; const hue = (bot.hue || null) as BotHue | null;
const snippet = const snippet =
subtitle ?? bot.status_line ||
(bot.last_message_text ? messagePreview({ text: bot.last_message_text }) : null) ?? subtitle ||
bot.role ?? (bot.last_message_text ? messagePreview({ text: bot.last_message_text }) : "") ||
bot.role ||
""; "";
const at = bot.last_message_at || bot.last_activity_at; const at = bot.last_message_at || bot.last_activity_at;
@@ -1,6 +1,6 @@
import Divider from "@moduletrace/twui/layout/Divider"; import Divider from "@moduletrace/twui/layout/Divider";
import type { BotWithLastMessage } from "@/src/types"; import type { BotWithLastMessage } from "@/src/types";
import { crewStatusLine } from "@/src/utils/crew-status"; import { crewCounts, crewStatusLine } from "@/src/utils/crew-status";
import BotRow from "./bot-row"; import BotRow from "./bot-row";
type Props = { type Props = {
@@ -10,16 +10,18 @@ type Props = {
}; };
/** /**
* Pinned first. Hue-less, and its subtitle is the crew status, not a * Pinned first. Hue-less. Its subtitle is its status line, else the crew
* snippet. * counts while any worker is busy or needs you, else its last message.
*/ */
export default function CoordinatorRow({ bot, bots, selected }: Props) { export default function CoordinatorRow({ bot, bots, selected }: Props) {
const { working, needs_you } = crewCounts({ bots });
return ( return (
<> <>
<BotRow <BotRow
bot={bot} bot={bot}
selected={selected} selected={selected}
subtitle={crewStatusLine({ bots })} subtitle={working || needs_you ? crewStatusLine({ bots }) : undefined}
/> />
<Divider className="shrink-0 mx-1 lg:mx-3 my-2 w-auto border-hairline dark:border-hairline" /> <Divider className="shrink-0 mx-1 lg:mx-3 my-2 w-auto border-hairline dark:border-hairline" />
</> </>
@@ -14,13 +14,13 @@ type Props = {
}; };
/** /**
* One computer in the header's computer select, checked when it's the * One computer in the header's computers modal, checked when it's the
* one the bot is on. * one the bot is on.
*/ */
export default function BotComputerOption({ computer, current, onPick }: Props) { export default function BotComputerOption({ computer, current, onPick }: Props) {
return ( return (
<Row <Row
role="menuitemradio" role="radio"
aria-checked={current} aria-checked={current}
tabIndex={0} tabIndex={0}
onClick={current ? undefined : onPick} onClick={current ? undefined : onPick}
@@ -1,90 +0,0 @@
import { useContext, useState } from "react";
import Dropdown from "@moduletrace/twui/elements/Dropdown";
import Paper from "@moduletrace/twui/elements/Paper";
import Button from "@moduletrace/twui/layout/Button";
import Span from "@moduletrace/twui/layout/Span";
import Stack from "@moduletrace/twui/layout/Stack";
import { ChevronDown, Monitor } from "lucide-react";
import type { BotWithLastMessage } from "@/src/types";
import { BotComputersContext } from "../(data)/bot-computers-context";
import BotComputerOption from "./bot-computer-option";
type Props = {
bot: BotWithLastMessage;
};
/**
* The header's computer button, beside the model button: shows the
* computer the bot is on and, with more than one, switches it.
*/
export default function BotComputerSelect({ bot }: Props) {
const { computers, switching_id, error, switchComputer } = useContext(BotComputersContext);
const [open, setOpen] = useState(false);
const current = computers[0];
if (!current?.id) return null;
const name = bot.name || "This bot";
const can_switch = computers.length > 1;
const target = (
<Button
title={
can_switch
? `${name} is on ${current.name}. Switch computers`
: `${name} is on ${current.name}, its only computer`
}
type="button"
variant="outlined"
color="gray"
size="small"
disabled={!can_switch}
loading={Boolean(switching_id)}
buttonContentProps={{ className: "min-w-0" }}
className="max-w-full min-w-0 gap-2! flex-nowrap text-[13px] text-ink-secondary hover:text-ink"
>
<Monitor size={16} aria-hidden className="shrink-0 -mt-0.5" />
<Span className="truncate">{current.name || "Computer"}</Span>
{can_switch ? <ChevronDown size={14} aria-hidden className="shrink-0" /> : null}
</Button>
);
if (!can_switch) {
return <Stack className="max-w-[200px] min-w-0 shrink">{target}</Stack>;
}
return (
<Dropdown
target={target}
position="bottom-right"
externalOpen={open}
externalSetOpen={setOpen}
targetWrapperProps={{ className: "max-w-full min-w-0" }}
className="max-w-[200px] min-w-0 shrink items-stretch"
>
<Paper
role="menu"
aria-label={`${name}'s computers`}
className="w-[260px] p-0! py-1 gap-0! items-stretch rounded-default bg-surface dark:bg-surface border-hairline dark:border-hairline shadow-popover! dark:shadow-popover!"
>
{computers.map((computer, index) => (
<BotComputerOption
key={computer.id}
computer={computer}
current={index == 0}
onPick={async () => {
setOpen(false);
await switchComputer({ computer_id: computer.id || 0 });
}}
/>
))}
{error ? (
<Span role="alert" className="px-4 py-2 text-[13px] leading-[18px] text-ink-secondary">
{error}
</Span>
) : null}
</Paper>
</Dropdown>
);
}
@@ -0,0 +1,82 @@
import { useContext, useState } from "react";
import Modal from "@moduletrace/twui/elements/Modal";
import H2 from "@moduletrace/twui/layout/H2";
import Span from "@moduletrace/twui/layout/Span";
import Stack from "@moduletrace/twui/layout/Stack";
import { Monitor } from "lucide-react";
import type { BotWithLastMessage } from "@/src/types";
import { BotComputersContext } from "../(data)/bot-computers-context";
import BotComputerOption from "./bot-computer-option";
import HeaderIconButton from "./header-icon-button";
type Props = {
bot: BotWithLastMessage;
};
/**
* The header's computer icon, beside the model icon: opens what the bot's
* computers are, the one it's on checked, and with more than one,
* switches it.
*/
export default function BotComputersModal({ bot }: Props) {
const { computers, switching_id, error, switchComputer } = useContext(BotComputersContext);
const [open, setOpen] = useState(false);
const current = computers[0];
if (!current?.id) return null;
const name = bot.name || "This bot";
const can_switch = computers.length > 1;
return (
<Modal
target={
<HeaderIconButton
icon={Monitor}
title={can_switch ? `Switch ${name}'s computer` : `About ${name}'s computer`}
loading={Boolean(switching_id)}
/>
}
targetWrapperProps={{ className: "shrink-0" }}
open={open}
setOpen={setOpen}
className="max-w-lg bg-paper p-6"
>
<Stack className="w-full gap-1! items-stretch">
<H2 className="text-lg! leading-6! tracking-normal! font-bold text-ink">
{can_switch ? `${name}'s computers` : `${name}'s computer`}
</H2>
<Span className="text-[13px] leading-[18px] text-ink-secondary">
{`A computer is a sandbox ${name} works in: a shell, a browser and files of its own. Its tasks run on the checked one.`}
</Span>
<Span className="text-[13px] leading-[18px] text-ink-secondary">
{can_switch
? `Pick another to move ${name} there. You can also click a computer it names in a message.`
: `This is the only computer ${name} can use right now.`}
</Span>
</Stack>
<Stack
role="radiogroup"
aria-label={`${name}'s computers`}
className="w-full mt-4 py-1 gap-0! items-stretch rounded-default border border-solid border-hairline bg-surface"
>
{computers.map((computer, index) => (
<BotComputerOption
key={computer.id}
computer={computer}
current={index == 0}
onPick={async () => {
await switchComputer({ computer_id: computer.id || 0 });
}}
/>
))}
</Stack>
{error ? (
<Span role="alert" className="mt-3 text-[13px] leading-[18px] text-ink-secondary">
{error}
</Span>
) : null}
</Modal>
);
}
@@ -1,29 +1,29 @@
import { useContext, useState } from "react"; import { useContext, useState } from "react";
import Modal from "@moduletrace/twui/elements/Modal"; import Modal from "@moduletrace/twui/elements/Modal";
import Button from "@moduletrace/twui/layout/Button";
import H2 from "@moduletrace/twui/layout/H2"; import H2 from "@moduletrace/twui/layout/H2";
import Span from "@moduletrace/twui/layout/Span"; import Span from "@moduletrace/twui/layout/Span";
import Stack from "@moduletrace/twui/layout/Stack"; import Stack from "@moduletrace/twui/layout/Stack";
import { ChevronDown, CpuAi } from "lucide-react"; import { CpuAi } from "lucide-react";
import { AppContext } from "@/src/pages/__root"; import { AppContext } from "@/src/pages/__root";
import type { BotWithLastMessage, HarnessOverview } from "@/src/types"; import type { BotWithLastMessage, HarnessOverview } from "@/src/types";
import isPassThroughUser from "@/src/utils/rbac/is-pass-through-user"; import isPassThroughUser from "@/src/utils/rbac/is-pass-through-user";
import BotHarnessForm from "./bot-harness-form"; import BotHarnessForm from "./bot-harness-form";
import HeaderIconButton from "./header-icon-button";
type Props = { type Props = {
bot: BotWithLastMessage; bot: BotWithLastMessage;
harnesses?: HarnessOverview[] | null; harnesses?: HarnessOverview[] | null;
onSaved: (harnesses: HarnessOverview[]) => void;
}; };
/** /**
* The header's model button: shows the model the bot thinks with and * The header's model icon: opens every model the bot uses (text, media
* opens every model it uses (text, media reading, transcription, speech, * reading, transcription, speech, images) in the Models settings form. On
* images) in the Models settings form. On the coordinator that form is the * the coordinator that form is the defaults, for super admins only;
* defaults, for super admins only; everyone else gets the button read only. * everyone else gets the icon disabled, its title saying why.
*/ */
export default function BotModelsModal({ bot, harnesses: initial }: Props) { export default function BotModelsModal({ bot, harnesses, onSaved }: Props) {
const { pageProps } = useContext(AppContext); const { pageProps } = useContext(AppContext);
const [harnesses, setHarnesses] = useState(initial);
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
if (!harnesses?.length || !bot.id) return null; if (!harnesses?.length || !bot.id) return null;
@@ -33,47 +33,27 @@ export default function BotModelsModal({ bot, harnesses: initial }: Props) {
!is_coordinator || !is_coordinator ||
isPassThroughUser({ user_types: pageProps?.user_types }); isPassThroughUser({ user_types: pageProps?.user_types });
const name = bot.name || "This bot"; const name = bot.name || "This bot";
const current =
harnesses.find((h) => h.harness == "text")?.current?.name ||
"No model set up";
const target = ( const target = (
<Button <HeaderIconButton
icon={CpuAi}
title={ title={
can_edit can_edit
? `${name}'s models` ? is_coordinator
: `${name} uses the default models. A super admin can change them here or in Settings, under Models.` ? "Change the default models"
: `Change ${name}'s models`
: `${name} uses the default models. A super admin can change them in Settings, under Models.`
} }
type="button"
variant="outlined"
color="gray"
size="small"
disabled={!can_edit} disabled={!can_edit}
buttonContentProps={{ className: "min-w-0" }} />
className="max-w-full min-w-0 gap-2! flex-nowrap text-[13px] text-ink-secondary hover:text-ink"
>
<CpuAi size={16} aria-hidden className="shrink-0 -mt-0.5" />
<Span className="truncate">{current}</Span>
{can_edit ? (
<ChevronDown size={14} aria-hidden className="shrink-0" />
) : null}
</Button>
); );
if (!can_edit) { if (!can_edit) return target;
return (
<Stack className="max-w-[280px] min-w-0 shrink">
{target}
</Stack>
);
}
return ( return (
<Modal <Modal
target={target} target={target}
targetWrapperProps={{ targetWrapperProps={{ className: "shrink-0" }}
className: "max-w-[280px] min-w-0 shrink",
}}
open={open} open={open}
setOpen={setOpen} setOpen={setOpen}
className="max-w-3xl bg-paper px-6 pt-6 pb-0" className="max-w-3xl bg-paper px-6 pt-6 pb-0"
@@ -84,15 +64,11 @@ export default function BotModelsModal({ bot, harnesses: initial }: Props) {
</H2> </H2>
<Span className="text-[13px] leading-[18px] text-ink-secondary"> <Span className="text-[13px] leading-[18px] text-ink-secondary">
{is_coordinator {is_coordinator
? `${name} and every bot left on the default use these. Providers are in Settings, under Models.` ? `${name} and every bot left on the default use these. The text model chats, plans and drives computers; the others read media, transcribe, speak and make images for it. Providers are in Settings, under Models.`
: `${name} uses the default models unless you pick its own.`} : `${name} uses the default models unless you pick its own. The text model chats, plans and drives computers; the others read media, transcribe, speak and make images for it.`}
</Span> </Span>
</Stack> </Stack>
<BotHarnessForm <BotHarnessForm bot={bot} harnesses={harnesses} onSaved={onSaved} />
bot={bot}
harnesses={harnesses}
onSaved={setHarnesses}
/>
</Modal> </Modal>
); );
} }
@@ -14,7 +14,7 @@ export default function ComposerAttachments({ pending, onRemove }: Props) {
if (!pending.length) return null; if (!pending.length) return null;
return ( return (
<Row className="gap-2! px-6 pb-3"> <Row className="w-full max-w-[760px] mx-auto gap-2! px-6 pb-3">
{pending.map((item) => ( {pending.map((item) => (
<ComposerAttachmentChip <ComposerAttachmentChip
key={item.key} key={item.key}
@@ -69,35 +69,37 @@ export default function Composer({ bot, onSent }: Props) {
}} }}
> >
<ComposerAttachments pending={files.pending} onRemove={files.remove} /> <ComposerAttachments pending={files.pending} onRemove={files.remove} />
<Row className="h-[88px] shrink-0 gap-4! flex-nowrap items-center px-6 border-0 border-t border-solid border-hairline"> <Stack className="shrink-0 gap-0! items-stretch border-0 border-t border-solid border-hairline">
<ComposerAttachButton disabled={files.full} onFiles={files.add} /> <Row className="w-full max-w-[760px] mx-auto h-[88px] gap-4! flex-nowrap items-center px-6">
<Textarea <ComposerAttachButton disabled={files.full} onFiles={files.add} />
name="message" <Textarea
aria-label={`Message ${bot.name}`} name="message"
placeholder={`Message ${bot.name}`} aria-label={`Message ${bot.name}`}
rows={1} placeholder={`Message ${bot.name}`}
value={text} rows={1}
onChange={(e) => setText(e.target.value)} value={text}
onKeyDown={handleKeyDown} onChange={(e) => setText(e.target.value)}
onPaste={(e) => { onKeyDown={handleKeyDown}
const pasted = [...e.clipboardData.files]; onPaste={(e) => {
const pasted = [...e.clipboardData.files];
if (!pasted.length) return; if (!pasted.length) return;
e.preventDefault(); e.preventDefault();
files.add({ files: pasted }); files.add({ files: pasted });
}} }}
aria-invalid={error ? true : undefined} aria-invalid={error ? true : undefined}
title={error} title={error}
className="field-sizing-content max-h-[100px] resize-none overflow-y-auto text-sm leading-5 text-ink placeholder:text-ink-secondary" className="field-sizing-content max-h-[100px] resize-none overflow-y-auto text-sm leading-5 text-ink placeholder:text-ink-secondary"
wrapperWrapperProps={{ className: "grow min-w-0" }} wrapperWrapperProps={{ className: "grow min-w-0" }}
wrapperProps={{ wrapperProps={{
className: className:
"p-0! outline-0! border-0! bg-transparent! dark:bg-transparent! rounded-none", "p-0! outline-0! border-0! bg-transparent! dark:bg-transparent! rounded-none",
}} }}
/> />
<ComposerSendButton disabled={!can_send} loading={sending || files.uploading} onClick={send} /> <ComposerSendButton disabled={!can_send} loading={sending || files.uploading} onClick={send} />
</Row> </Row>
</Stack>
</Stack> </Stack>
); );
} }
@@ -1,17 +1,18 @@
import { useContext, useState } from "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 H1 from "@moduletrace/twui/layout/H1"; import H1 from "@moduletrace/twui/layout/H1";
import Span from "@moduletrace/twui/layout/Span"; import { CpuAi, Monitor } from "lucide-react";
import BotStone from "@/src/components/general/bot-stone"; import BotStone from "@/src/components/general/bot-stone";
import type { BotFace, BotHue } from "@/src/dict/bot-dict"; import type { BotFace, BotHue } from "@/src/dict/bot-dict";
import type { BotWithLastMessage, HarnessOverview } from "@/src/types"; import type { BotWithLastMessage, HarnessOverview } from "@/src/types";
import { crewStatusSentence } from "@/src/utils/crew-status"; import { BotComputersContext } from "../(data)/bot-computers-context";
import BotComputerSelect from "./bot-computer-select"; import BotComputersModal from "./bot-computers-modal";
import BotModelsModal from "./bot-models-modal"; import BotModelsModal from "./bot-models-modal";
import HeaderMetaItem from "./header-meta-item";
type Props = { type Props = {
bot: BotWithLastMessage; bot: BotWithLastMessage;
bots: BotWithLastMessage[];
/** /**
* The bot's models (the defaults for the coordinator). * The bot's models (the defaults for the coordinator).
*/ */
@@ -19,16 +20,17 @@ type Props = {
}; };
/** /**
* 64px header. The coordinator's status is the crew sentence. On the * 64px header. Under the name, what the bot runs on: its text model and
* right, the computer the bot is on (`BotComputerSelect`), then the model * provider, and the computer it's on. On the right, ghost icon buttons
* button that opens the bot's models (the defaults on the coordinator, * that open the computers and models modals. Kept here so a save or a
* for super admins). * switch shows under the name at once.
*/ */
export default function ConversationHeader({ bot, bots, harnesses }: Props) { export default function ConversationHeader({ bot, harnesses: initial }: Props) {
const status = const [harnesses, setHarnesses] = useState(initial);
bot.kind === "coordinator" const { computers } = useContext(BotComputersContext);
? crewStatusSentence({ bots })
: bot.status_line || ""; const model = harnesses?.find((h) => h.harness == "text")?.current;
const computer = computers[0];
return ( return (
<Row className="h-16 shrink-0 gap-3! flex-nowrap items-center px-6 border-0 border-b border-solid border-hairline"> <Row className="h-16 shrink-0 gap-3! flex-nowrap items-center px-6 border-0 border-b border-solid border-hairline">
@@ -38,16 +40,36 @@ export default function ConversationHeader({ bot, bots, harnesses }: Props) {
size={40} size={40}
/> />
<Stack className="min-w-0 gap-0!"> <Stack className="min-w-0 gap-0!">
<H1 className="text-lg! leading-6! tracking-normal! font-bold text-ink truncate"> <H1 className="text-lg! leading-6! tracking-normal! font-bold text-ink truncate w-full">
{bot.name} {bot.name}
</H1> </H1>
<Span className="text-[13px] leading-[18px] text-ink-secondary truncate"> <Row className="min-w-0 w-full gap-x-3! gap-y-0! flex-nowrap">
{status} <HeaderMetaItem
</Span> icon={CpuAi}
label="Model"
text={
model
? `${model.name} · ${model.provider_name}`
: "No model set up"
}
/>
{computer?.id ? (
<HeaderMetaItem
icon={Monitor}
label="Computer"
text={computer.name || `Computer ${computer.id}`}
/>
) : null}
</Row>
</Stack> </Stack>
<Row className="ml-auto min-w-0 shrink gap-2! flex-nowrap justify-end"> <Row className="ml-auto shrink-0 gap-1! flex-nowrap justify-end">
<BotComputerSelect bot={bot} /> <BotComputersModal bot={bot} />
<BotModelsModal key={bot.id} bot={bot} harnesses={harnesses} /> <BotModelsModal
key={bot.id}
bot={bot}
harnesses={harnesses}
onSaved={setHarnesses}
/>
</Row> </Row>
</Row> </Row>
); );
@@ -0,0 +1,33 @@
import Button from "@moduletrace/twui/layout/Button";
import type { LucideIcon } from "lucide-react";
type Props = {
icon: LucideIcon;
/**
* Says what the button does; it has no visible label.
*/
title: string;
disabled?: boolean;
loading?: boolean;
};
/**
* A ghost icon button on the right of the conversation header. Used as a
* modal's target, so it takes no click handler of its own.
*/
export default function HeaderIconButton({ icon: Icon, title, disabled, loading }: Props) {
return (
<Button
title={title}
aria-label={title}
type="button"
variant="ghost"
color="text"
disabled={disabled}
loading={loading}
className="shrink-0 p-2! text-ink-secondary dark:text-ink-secondary hover:text-ink hover:dark:text-ink hover:bg-sunken hover:dark:bg-sunken rounded-default transition-colors duration-[var(--m-fast)] focus-visible:ring-2 focus-visible:ring-ink"
>
<Icon size={18} aria-hidden />
</Button>
);
}
@@ -0,0 +1,28 @@
import Row from "@moduletrace/twui/layout/Row";
import Span from "@moduletrace/twui/layout/Span";
import type { LucideIcon } from "lucide-react";
type Props = {
icon: LucideIcon;
/**
* What the text is, for the tooltip and screen readers.
*/
label: string;
text: string;
};
/**
* One fact under the bot's name in the conversation header: an icon and
* a line of muted text.
*/
export default function HeaderMetaItem({ icon: Icon, label, text }: Props) {
return (
<Row
title={`${label}: ${text}`}
className="min-w-0 shrink gap-1.5! flex-nowrap text-[13px] leading-[18px] text-ink-secondary"
>
<Icon size={13} aria-label={label} className="shrink-0" />
<Span className="truncate">{text}</Span>
</Row>
);
}
@@ -23,7 +23,7 @@ export default function MessageThread({ bot, messages }: Props) {
role="log" role="log"
aria-live="polite" aria-live="polite"
aria-label={`Conversation with ${bot.name}`} aria-label={`Conversation with ${bot.name}`}
className="w-full gap-4! items-stretch px-6 pt-6 pb-6" className="w-full max-w-[760px] mx-auto gap-4! items-stretch px-6 pt-6 pb-6"
> >
{groups.map((group) => ( {groups.map((group) => (
<MessageGroupBlock key={group.key} bot={bot} group={group} /> <MessageGroupBlock key={group.key} bot={bot} group={group} />
@@ -1,19 +0,0 @@
import Button from "@moduletrace/twui/layout/Button";
/**
* Opens the machine the bot is working on. Disabled until the task has one.
*/
export default function OpenComputerButton({ url }: { url?: string | null }) {
return (
<Button
title="Open computer"
color="gray"
href={url || undefined}
target="_blank"
disabled={!url}
className="w-full h-9 rounded-default text-[13px] font-bold bg-surface dark:bg-surface hover:bg-sunken hover:dark:bg-sunken text-ink dark:text-ink border border-solid border-hairline-strong transition-colors duration-[var(--m-fast)] focus-visible:ring-2 focus-visible:ring-ink focus-visible:ring-offset-2 focus-visible:ring-offset-paper"
>
Open computer
</Button>
);
}
@@ -1,28 +0,0 @@
import Span from "@moduletrace/twui/layout/Span";
import { Check } from "lucide-react";
/**
* 14px step mark. Pending has no mark; its label is muted.
*/
export default function TaskStepMark({ state }: { state: string }) {
if (state === "done") {
return (
<Check
size={14}
strokeWidth={1.6 * (24 / 14)}
className="shrink-0 text-ink"
aria-label="Done"
/>
);
}
if (state === "active") {
return (
<Span className="size-3.5 shrink-0 flex items-center justify-center" aria-label="In progress" role="img">
<Span className="size-1.5 rounded-full bg-ink" />
</Span>
);
}
return <Span className="size-3.5 shrink-0" aria-hidden />;
}
@@ -1,34 +0,0 @@
import Row from "@moduletrace/twui/layout/Row";
import Span from "@moduletrace/twui/layout/Span";
import { twMerge } from "tailwind-merge";
import TaskStepMark from "./task-step-mark";
type Props = {
label: string;
state: string;
};
/**
* 28px pitch. Done is a tick, active a dot with a bold label.
*/
export default function TaskStepRow({ label, state }: Props) {
const is_active = state === "active";
return (
<Row
className="h-7 gap-2.5! flex-nowrap items-center"
aria-current={is_active ? "step" : undefined}
>
<TaskStepMark state={state} />
<Span
className={twMerge(
"text-[13px] leading-5 truncate",
is_active ? "font-bold text-ink" : "text-ink-secondary",
state === "pending" && "text-ink-disabled",
)}
>
{label}
</Span>
</Row>
);
}
@@ -12,8 +12,9 @@ import MessageThread from "../(partials)/message-thread";
import Composer from "../(partials)/composer"; import Composer from "../(partials)/composer";
/** /**
* The 900px conversation column: header, bottom-anchored thread, composer. * The conversation column: header, bottom-anchored thread, composer, the
* The bot's computers are shared with the header's select and the computer * thread and composer held to a readable width.
* The bot's computers are shared with the header's modal and the computer
* buttons in the thread, so a switch from either shows in both. * buttons in the thread, so a switch from either shows in both.
*/ */
export default function Conversation() { export default function Conversation() {
@@ -56,7 +57,6 @@ export default function Conversation() {
<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}
bots={bots}
harnesses={pageProps?.bot_harness_overview?.harnesses} harnesses={pageProps?.bot_harness_overview?.harnesses}
/> />
<MessageThread bot={live_bot} messages={messages} /> <MessageThread bot={live_bot} messages={messages} />
@@ -1,83 +0,0 @@
import { useContext, useState } from "react";
import Stack from "@moduletrace/twui/layout/Stack";
import Span from "@moduletrace/twui/layout/Span";
import H2 from "@moduletrace/twui/layout/H2";
import { AppContext } from "@/src/pages/__root";
import useServerEvent from "@/src/hooks/use-server-event";
import { AppData } from "@/src/data/app-data";
import type { BotJobUpdateData, BotUpdatedData } from "@/src/types";
import TaskStepRow from "../(partials)/task-step-row";
import OpenComputerButton from "../(partials)/open-computer-button";
/**
* 280px panel listing the steps the bot is taking, kept current by the
* active task each `server:bot-updated` push carries. Without planned
* steps it shows what the task's job has done (`bots_queue_updates`), the
* newest as in progress, appended live from `server:bot-job-update`.
* Hidden below 1280px.
*/
export default function WorkingOnPanel() {
const { pageProps } = useContext(AppContext);
const bot_id = pageProps?.bot?.id;
const [task, setTask] = useState(pageProps?.bot_task);
useServerEvent({
event: "server:bot-updated",
onData: (data: BotUpdatedData) => {
if (data.id == bot_id) setTask(data.task);
},
});
useServerEvent({
event: "server:bot-job-update",
onData: (data: BotJobUpdateData) => {
setTask((prev) =>
prev?.id && prev.id == data.task_id && !prev.updates.some((u) => u.id == data.id)
? { ...prev, updates: [...prev.updates, data].slice(-AppData.BotJobUpdatesShown) }
: prev,
);
},
});
return (
<Stack
className="hidden xl:flex w-[280px] shrink-0 h-full gap-0! items-stretch px-6 border-0 border-l border-solid border-hairline"
aria-label="Working on"
>
<Span className="pt-[34px] text-xs font-bold leading-4 tracking-[0.06em] uppercase text-ink-secondary">
Working on
</Span>
{task ? (
<>
<H2 className="mt-2.5 text-sm! leading-5! tracking-normal! font-bold text-ink">
{task.title}
</H2>
<Stack className="mt-4 gap-0! items-stretch">
{task.steps.length
? task.steps.map((step) => (
<TaskStepRow
key={step.id}
label={step.label || ""}
state={step.state || "pending"}
/>
))
: task.updates.map((update, index) => (
<TaskStepRow
key={update.id}
label={update.text || ""}
state={index == task.updates.length - 1 ? "active" : "done"}
/>
))}
</Stack>
</>
) : (
<Span className="mt-2.5 text-[13px] leading-5 text-ink-secondary">
Nothing in progress.
</Span>
)}
<Stack className="mt-auto h-[88px] shrink-0 gap-0! items-stretch justify-center">
{task ? <OpenComputerButton url={task.computer_url} /> : null}
</Stack>
</Stack>
);
}
@@ -1,7 +1,6 @@
import userAuth from "@/src/functions/backend/auth/user-auth"; import userAuth from "@/src/functions/backend/auth/user-auth";
import grabBots from "@/src/functions/backend/db/bots/grab-bots"; import grabBots from "@/src/functions/backend/db/bots/grab-bots";
import grabBotMessages from "@/src/functions/backend/db/bots/grab-bot-messages"; import grabBotMessages from "@/src/functions/backend/db/bots/grab-bot-messages";
import grabActiveBotTask from "@/src/functions/backend/db/bots/grab-active-bot-task";
import markBotRead from "@/src/functions/backend/db/bots/mark-bot-read"; import markBotRead from "@/src/functions/backend/db/bots/mark-bot-read";
import grabBotComputers from "@/src/functions/backend/db/computers/grab-bot-computers"; import grabBotComputers from "@/src/functions/backend/db/computers/grab-bot-computers";
import grabBotHarnessOverview from "@/src/functions/backend/db/harness/grab-bot-harness-overview"; import grabBotHarnessOverview from "@/src/functions/backend/db/harness/grab-bot-harness-overview";
@@ -41,9 +40,8 @@ export const server: BunextPageServerFn<PagePropsType> = async (params) => {
* The header's models modal: a worker's own settings, or for the * The header's models modal: a worker's own settings, or for the
* coordinator the defaults it runs on (without the providers). * coordinator the defaults it runs on (without the providers).
*/ */
const [bot_messages, bot_task, bot_harness_overview, bot_computers] = await Promise.all([ const [bot_messages, bot_harness_overview, bot_computers] = await Promise.all([
grabBotMessages({ bot_id: bot.id }), grabBotMessages({ bot_id: bot.id }),
grabActiveBotTask({ bot_id: bot.id }),
(bot.kind == "coordinator" (bot.kind == "coordinator"
? grabHarnessOverview().then(({ harnesses }) => ({ harnesses })) ? grabHarnessOverview().then(({ harnesses }) => ({ harnesses }))
: grabBotHarnessOverview({ bot_id: bot.id }) : grabBotHarnessOverview({ bot_id: bot.id })
@@ -56,7 +54,6 @@ export const server: BunextPageServerFn<PagePropsType> = async (params) => {
bots, bots,
bot, bot,
bot_messages, bot_messages,
bot_task,
bot_harness_overview, bot_harness_overview,
bot_computers, bot_computers,
}, },
+1 -7
View File
@@ -2,15 +2,9 @@ import type { PagePropsType } from "@/src/types";
import type { BunextPageModuleMetaFn } from "@moduletrace/bunext/types"; import type { BunextPageModuleMetaFn } from "@moduletrace/bunext/types";
import { SiteData } from "@/src/data/site-data"; import { SiteData } from "@/src/data/site-data";
import Conversation from "./(sections)/conversation"; import Conversation from "./(sections)/conversation";
import WorkingOnPanel from "./(sections)/working-on-panel";
export default function BotConversationPage() { export default function BotConversationPage() {
return ( return <Conversation />;
<>
<Conversation />
<WorkingOnPanel />
</>
);
} }
export const meta: BunextPageModuleMetaFn = async (params) => { export const meta: BunextPageModuleMetaFn = async (params) => {
-1
View File
@@ -273,7 +273,6 @@ export type PagePropsType = {
bots?: BotWithLastMessage[] | null; bots?: BotWithLastMessage[] | null;
bot?: BotWithLastMessage | null; bot?: BotWithLastMessage | null;
bot_messages?: BotMessageWithAttachments[] | null; bot_messages?: BotMessageWithAttachments[] | null;
bot_task?: BotTaskWithSteps | null;
/** /**
* The open bot's computers, the one it's on first. * The open bot's computers, the one it's on first.
*/ */