diff --git a/AGENTS.md b/AGENTS.md index 473ef51..5d72325 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -92,13 +92,13 @@ Inspect live data with `bun llm-code-assistant/query-db.ts ""` - a read-onl - **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; `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. - **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; while a task runs it reads " is working…" with the task's latest step. 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 " is working…" with the task's latest step. 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. - **Pictures:** a bot or workspace can have a picture (`image_media_id`, a private `media` row, paradigm `bot-image`/`workspace-image`), set or removed through `api/admin/set-profile-image.ts` (`setProfileImage`, the old one deleted; served at `profileImageUrls`). `BotAvatar` shows a bot's picture as a rounded square, else its stone; its hue still applies. `WorkspaceAvatar` shows a workspace's, else the workspace icon. A bot is edited from the header avatar (`(partials)/bot-profile-modal.tsx`, the same `BotForm` as adding one, `api/admin/bots/update-bot.ts`; the coordinator keeps its name and stays hue-less). - **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). - **Prompt and memory (self-learning):** a bot has `system_prompt` (core instructions) and a separate core `memory` (markdown, general knowledge about its job, the same for every user it works for, never about one user). What it remembers about a user lives in `bot_user_memories` (one row per bot and user, seen only by that user's runs; `grab-bot-user-memory`, `update-bot-user-memory`), like workspace memory stays in its workspace. The bot rewrites them as it adapts (`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 for the run's user via `composeBotPrompt` (`src/utils/compose-bot-prompt.ts`): core instructions, then (in a workspace only) `# Workspace: ` with its description and `system_prompt`, the bot's `# Your instructions in ` (`workspace_bots.system_prompt`), `# Memory`, `# Memory about the user`, then `# Memory in ` (`workspace_bots.memory`). `update_memory` saves to `user` by default (`workspace` in a workspace) and to `core` only for what holds for everyone. In a workspace the bot learns into the workspace's instructions and memory by default (`scope: workspace` on `update_instructions`/`update_memory`, `WorkspaceLearningNote`) and into its core ones only when told to, or after suggesting it. `src/scripts/move-bot-memory-to-owners.ts` (run once per install) moved core memory written before this into the owner's. - **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 for the job's user), `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`); the conversation (`bot_task` page prop, `use-task-progress`) shows the latest one under `BotThinkingRow`'s " is working…" while a task runs. Step text is the first line the model wrote, else plain labels for its tools (`JobStepToolLabels` in `src/dict/bot-queue-dict.ts`). -- **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 session via `createKeyLock`, so a bot's other sessions answer side by side; a task waiting for its busy computer shows face `asleep` with `BotComputerBusyStatusLine`; face `working` once it starts, outcome pushed live; a job finishing while others for that user still run puts back the running one's face, `grabRunningBotJobFace`). 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. Every process also runs `liveRelay` (`src/live-relay/`): every `AppData.LiveRelayPollMs` it reads new `bot_messages`, new `bots_queue_updates`, changed faces 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 bot that joins the crew or a workspace is pushed as `server:bot-created` (`BotCreatedData`: its rail row, the workspaces it's in, and whether it belongs in that user's own chat), from the tool that created it and from the live relay for other processes, so the rail lists it without a reload. A workspace a bot created is pushed the same way as `server:workspace-created` (`WorkspaceCreatedData`: its rail row), from the tool and from the live relay. A deleted bot is pushed as `server:bot-removed` (`BotRemovedData`) to the audience read before the delete, since its assignments go with it, so its rail row goes at once. 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` (`name`, `description`, `system_prompt` shared by every bot in it, `computer_id` its default computer (not a foreign key), `archived`), with bots assigned through `workspace_bots` (plus that bot's `system_prompt` and `memory` there; the coordinator is an implicit member) and computers through `workspace_computers`. The first computer linked becomes the default; the user (workspace settings, chat header), any bot there (`set_workspace_computer`) or the coordinator can switch it. A workspace with no computer has none: tasks needing one fail. Workspace models (`workspace_harness_settings`) win over a bot's own, then the defaults. Helpers in `src/functions/backend/db/workspaces/` (`create-workspace`, `update-workspace`, `delete-workspace`, `add/remove-bot-*`, `update-workspace-bot`, `add/remove-computer-*`, `set-workspace-computer`, `grab-user-workspaces`, `grab-workspace-bots`, `grab-workspace-computers`, `grab-workspace-activity`), all access-checked against the session user, the structural ones for super admins and admins only; API in `api/admin/workspaces/`. The coordinator sets a member's instructions there with `set_bot_workspace_instructions`, creates a workspace with `create_workspace`, and changes any workspace's name, description or context with `update_workspace` (chat replies, and its own tasks so findings it was asked to record land in the workspace; a chat-created one is pushed as `server:workspace-created`). - **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` (the install's, `kind` main|sandbox, `container_id`) and `bot_computers` (direct access). The app creates and manages every computer's container over the docker socket (`src/functions/backend/docker/`; `DOCKER_SOCKET` or `AppData.DockerSocketPath`): `createComputer` records it and `provisionComputer` (in the background, status `creating`, then the container's or `failed` with `metadata.error`, pushed as `server:computer-updated`) pulls `AppData.ComputerImage`, binds the home dir and starts `-` limited to the computer's `cpus` and `memory_mb` (set at creation, default `AppData.ComputerDefaultCpus`/`ComputerDefaultMemoryMb`: 1 CPU, 2 GB), attached to the network in the same create call, with a docker health check (`AppData.ComputerHealthcheck`: the sandbox API on `ComputerPort` and its browser); the computer stays `creating` until it passes (`waitForHealthyContainer`), then records its IP address on the network (`containers.ip_address`, refreshed by `sync-computers`). `updateComputer` (`api/admin/computers/update-computer`) saves name, description and resources; new resources stop, remove and recreate the container (`provisionComputer`), the home dir kept. A computer's home (`/home/gem`) is `DATA_DIR/computers/` (`AppData.ComputerDataDir`, owned by uid 1000); deleting the computer (`deleteComputer`, never the main one) removes the container and keeps that directory. Run in docker, the app resolves host paths from its own container's mounts and joins the network; outside docker (dev) it can't resolve container hostnames, so it reaches them at their IP address (`pickReachableAddress`: hostname first, then the IP, the pick cached for `AppData.ComputerAddressCacheMs`). Only super admins create, delete or retry computers (`api/admin/computers/`). The `sync-computers` cron records container states every five minutes. The install has one main computer, created at signup (`bun run seed`) and as a fallback by the super admin's `/admin/bots` via `ensureMainComputer` (`src/functions/backend/db/computers/ensure-main-computer.ts`, idempotent), which grants the coordinator access; with `MAIN_COMPUTER_URL` set (dev) it isn't provisioned. 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`): in a workspace, the workspace's computers, its default first; otherwise its direct grants (the coordinator's are every computer), 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`. diff --git a/src/bots-queue/process-bot-job.ts b/src/bots-queue/process-bot-job.ts index fb2460b..ae2eaa5 100644 --- a/src/bots-queue/process-bot-job.ts +++ b/src/bots-queue/process-bot-job.ts @@ -9,7 +9,12 @@ import { logError } from "@/src/functions/backend/logger"; import createKeyLock from "@/src/utils/create-key-lock"; import HarnessError from "@/src/harness/harness-error"; import deferBotJob from "@/src/functions/backend/db/bots-queue/defer-bot-job"; -import { BotReplyJobTitle, BotTaskStatusLine, BotWaitingStatusLine } from "@/src/dict/bot-queue-dict"; +import { + BotComputerBusyStatusLine, + BotReplyJobTitle, + BotTaskStatusLine, + BotWaitingStatusLine, +} from "@/src/dict/bot-queue-dict"; import BotJobFatalError from "./bot-job-fatal-error"; import describeJobStep from "./describe-job-step"; import pushToSessionUser from "./push-to-session-user"; @@ -26,7 +31,8 @@ import type { BotJobContext, BotJobRunner } from "./types"; const computer_lock = createKeyLock(); /** - * One reply per bot at a time, so replies never cross in the chat. + * One reply per conversation at a time, so replies never cross in the + * chat. A bot's other sessions answer side by side. */ const reply_lock = createKeyLock(); @@ -133,11 +139,23 @@ export default async function processBotJob({ }); }; + /** + * A task whose computer is busy says so while it waits, so the + * user doesn't see silence. + */ + const runOnComputer = async (computer_id: number) => { + if (computer_lock.busy({ key: computer_id })) { + await setFace({ face: "asleep", status_line: BotComputerBusyStatusLine }); + } + + return await computer_lock.run({ key: computer_id, task: run }); + }; + const outcome = job.kind == "reply" - ? await reply_lock.run({ key: bot_id, task: run }) + ? await reply_lock.run({ key: job.session_id || `bot-${bot_id}`, task: run }) : job.computer_id - ? await computer_lock.run({ key: job.computer_id, task: run }) + ? await runOnComputer(job.computer_id) : await run(); if (controller.signal.aborted) return; diff --git a/src/dict/bot-queue-dict.ts b/src/dict/bot-queue-dict.ts index 30c6cc8..b700418 100644 --- a/src/dict/bot-queue-dict.ts +++ b/src/dict/bot-queue-dict.ts @@ -55,6 +55,12 @@ export const BotNeedsYouStatusLine = "Waiting on you"; */ export const BotWaitingStatusLine = "Waiting for a free model"; +/** + * The bot's status line while its task waits for another job on the same + * computer to finish. + */ +export const BotComputerBusyStatusLine = "Waiting for its computer"; + /** * How a job step that called a tool reads in the chat's working row, by * tool name. Tools not listed read "Using ". diff --git a/src/functions/backend/db/bots-queue/complete-bot-job.ts b/src/functions/backend/db/bots-queue/complete-bot-job.ts index c47bcc9..9999cef 100644 --- a/src/functions/backend/db/bots-queue/complete-bot-job.ts +++ b/src/functions/backend/db/bots-queue/complete-bot-job.ts @@ -8,6 +8,7 @@ import grabLockedBotJob from "./grab-locked-bot-job"; import relayJobToRequester from "./relay-job-to-requester"; import jobSessionId from "./job-session-id"; import grabJobUserId from "./grab-job-user-id"; +import grabRunningBotJobFace from "./grab-running-bot-job-face"; type Params = { job_id: number | string; @@ -31,7 +32,9 @@ type Params = { * Marks a job done, closes its task and sends the completion message back * to the user in the bot's conversation. Unless it has other jobs open * for that user, the bot shows that user needs-you if asked, else done - * after a task and idle after a reply; nobody else's face changes. A handed-over job's result also goes to the bot that handed + * after a task and idle after a reply; with others open, it shows the one + * still running (a reply in another session mustn't hide a task's row). + * Nobody else's face changes. A handed-over job's result also goes to the bot that handed * it over (`relay`). Throws if this handler no longer holds the job. */ export default async function completeBotJob({ @@ -92,7 +95,7 @@ export default async function completeBotJob({ status_line: BotNeedsYouStatusLine, }) : (await countOpenBotJobs({ bot_id: job.bot_id, user_id })) - ? null + ? await restoreRunningFace({ bot_id: job.bot_id, user_id }) : await setBotFace({ bot_id: job.bot_id, user_id, @@ -103,3 +106,12 @@ export default async function completeBotJob({ return { message, bot, relay }; } + +/** + * Puts back the face of a job still running for the user, if any. + */ +async function restoreRunningFace({ bot_id, user_id }: { bot_id: number; user_id: number }) { + const running = await grabRunningBotJobFace({ bot_id, user_id }); + + return running ? await setBotFace({ bot_id, user_id, ...running }) : null; +} diff --git a/src/functions/backend/db/bots-queue/grab-running-bot-job-face.ts b/src/functions/backend/db/bots-queue/grab-running-bot-job-face.ts new file mode 100644 index 0000000..8c2a7cc --- /dev/null +++ b/src/functions/backend/db/bots-queue/grab-running-bot-job-face.ts @@ -0,0 +1,34 @@ +import BunSQLite from "@moduletrace/bun-sqlite"; +import type { BotFace } from "@/src/dict/bot-dict"; +import { BotReplyJobTitle, BotTaskStatusLine } from "@/src/dict/bot-queue-dict"; + +type Params = { + bot_id: number | string; + /** + * Only jobs in this user's conversations. + */ + user_id: number | string; +}; + +/** + * The face a bot should show one user while other jobs for them are + * still running: thinking while a reply runs (it's short, and its chat + * needs the row), else working on a task. Null when none is running. + */ +export default async function grabRunningBotJobFace({ + bot_id, + user_id, +}: Params): Promise<{ face: BotFace; status_line: string } | null> { + const kinds = ( + await BunSQLite.sql<{ kind: string }>({ + sql: `SELECT DISTINCT kind FROM bots_queue WHERE bot_id = ? AND status = 'processing' + AND session_id IN (SELECT id FROM bot_sessions WHERE user_id = ?)`, + values: [bot_id, user_id], + }) + ).payload?.map((row) => row.kind) || []; + + if (kinds.includes("reply")) return { face: "working", status_line: BotReplyJobTitle }; + if (kinds.includes("task")) return { face: "working", status_line: BotTaskStatusLine }; + + return null; +} diff --git a/src/pages/admin/bots/[bot_id]/(partials)/bot-thinking-row.tsx b/src/pages/admin/bots/[bot_id]/(partials)/bot-thinking-row.tsx index 7739524..17c505d 100644 --- a/src/pages/admin/bots/[bot_id]/(partials)/bot-thinking-row.tsx +++ b/src/pages/admin/bots/[bot_id]/(partials)/bot-thinking-row.tsx @@ -3,7 +3,11 @@ import Span from "@moduletrace/twui/layout/Span"; import Stack from "@moduletrace/twui/layout/Stack"; import BotAvatar from "@/src/components/general/bot-avatar"; import type { BotHue } from "@/src/dict/bot-dict"; -import { BotReplyJobTitle, BotWaitingStatusLine } from "@/src/dict/bot-queue-dict"; +import { + BotComputerBusyStatusLine, + BotReplyJobTitle, + BotWaitingStatusLine, +} from "@/src/dict/bot-queue-dict"; import type { BotWithLastMessage } from "@/src/types"; type Props = { @@ -24,17 +28,23 @@ type Props = { /** * Under the last message while the bot is working on a reply or a task - * (or waiting for a model to come back), so slow work doesn't look like - * none. A task shows its latest step. The bot's face is shared by - * everyone who sees it, so the row only shows for work in this - * conversation: a reply while the user has the last word, or its own - * task. + * (or waiting for a model or its computer), so slow work doesn't look like + * none. A task shows its latest step. The bot's face is one per user, + * shared by all their sessions with it, so a reply shows from the thread + * alone: every user message queues one, so while the user has the last + * word a reply is due here, even while the bot works elsewhere. Only the + * waits (for a model, for its computer) come from the face. */ export default function BotThinkingRow({ bot, progress, awaiting_reply, has_task }: Props) { const name = bot.name || "The bot"; - const thinking = bot.face == "working" && bot.status_line == BotReplyJobTitle && Boolean(awaiting_reply); - const working = bot.face == "working" && bot.status_line != BotReplyJobTitle && Boolean(has_task); - const waiting = bot.face == "asleep" && bot.status_line == BotWaitingStatusLine && Boolean(awaiting_reply || has_task); + const asleep = bot.face == "asleep"; + const waiting_model = asleep && bot.status_line == BotWaitingStatusLine && Boolean(awaiting_reply || has_task); + const waiting_computer = + asleep && bot.status_line == BotComputerBusyStatusLine && Boolean(has_task) && !awaiting_reply; + const waiting = waiting_model || waiting_computer; + const thinking = Boolean(awaiting_reply) && !waiting; + const working = + !awaiting_reply && bot.face == "working" && bot.status_line != BotReplyJobTitle && Boolean(has_task); if (!thinking && !working && !waiting) return null; @@ -42,7 +52,7 @@ export default function BotThinkingRow({ bot, progress, awaiting_reply, has_task ? `${name} is thinking…` : working ? `${name} is working…` - : `${BotWaitingStatusLine}…`; + : `${bot.status_line}…`; const detail = working ? progress : null; return ( diff --git a/src/utils/create-key-lock.test.ts b/src/utils/create-key-lock.test.ts index 12e9d8b..6c6e4e5 100644 --- a/src/utils/create-key-lock.test.ts +++ b/src/utils/create-key-lock.test.ts @@ -65,4 +65,19 @@ describe("createKeyLock", () => { expect(failed).rejects.toThrow("boom"); expect(await next).toBe("ok"); }); + + test("is busy while a task with the key is running", async () => { + const lock = createKeyLock(); + + expect(lock.busy({ key: 1 })).toBe(false); + + const running = lock.run({ key: 1, task: () => wait(10) }); + + expect(lock.busy({ key: 1 })).toBe(true); + expect(lock.busy({ key: 2 })).toBe(false); + + await running; + + expect(lock.busy({ key: 1 })).toBe(false); + }); }); diff --git a/src/utils/create-key-lock.ts b/src/utils/create-key-lock.ts index 073cf98..9b0c55d 100644 --- a/src/utils/create-key-lock.ts +++ b/src/utils/create-key-lock.ts @@ -28,5 +28,13 @@ export default function createKeyLock() { } } - return { run, size: () => tails.size }; + /** + * Whether a task with this key is running or queued, so a caller can + * say it's waiting before it joins the line. + */ + function busy({ key }: { key: string | number }) { + return tails.has(String(key)); + } + + return { run, busy, size: () => tails.size }; }