From 61098a114cf16774cdd5f1929b7f8a020baeb57d Mon Sep 17 00:00:00 2001 From: Benjamin Toby Date: Mon, 19 Oct 2026 16:28:53 +0100 Subject: [PATCH] Show a reply's steps, and catch up on a reply whose live push was missed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A reply that works through tools now shows its latest tool step under "is thinking…" (not its closing note, which is the answer). While a reply is due, the open conversation checks the session's latest messages every few seconds (api/admin/bots/session-messages) and replays any it missed as server:bot-message, so the thread, rail and unread all take it and the thinking row can't stay up until a reload. MCP and custom tool steps read in words ("Using Exa search (web search exa)"). Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 6 +- src/bots-queue/describe-job-step.test.ts | 15 ++++- src/bots-queue/describe-job-step.ts | 18 +++++- src/data/app-data.ts | 10 ++++ .../[bot_id]/(functions)/post-session-api.ts | 2 +- .../[bot_id]/(hooks)/use-reply-catch-up.ts | 60 +++++++++++++++++++ .../[bot_id]/(hooks)/use-reply-progress.ts | 53 ++++++++++++++++ .../[bot_id]/(partials)/bot-thinking-row.tsx | 10 +++- .../[bot_id]/(partials)/message-thread.tsx | 7 ++- .../bots/[bot_id]/(sections)/conversation.tsx | 17 ++++++ src/pages/api/admin/bots/session-messages.ts | 51 ++++++++++++++++ 11 files changed, 238 insertions(+), 11 deletions(-) create mode 100644 src/pages/admin/bots/[bot_id]/(hooks)/use-reply-catch-up.ts create mode 100644 src/pages/admin/bots/[bot_id]/(hooks)/use-reply-progress.ts create mode 100644 src/pages/api/admin/bots/session-messages.ts diff --git a/AGENTS.md b/AGENTS.md index 5d72325..10f6155 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -90,14 +90,14 @@ Inspect live data with `bun llm-code-assistant/query-db.ts ""` - a read-onl - **Threads and sessions:** each user has one conversation with a bot in its own chat and one per workspace it's in (the coordinator in every workspace). Each is split into `bot_sessions` (`user_id`, `bot_id`, `workspace_id` null for its own chat, `status` active|closed, `title` from the first user message, `summary`, `previous_session_id`, `unread`, `last_activity_at`); `bot_messages`, `bot_tasks` and `bots_queue` carry `session_id` and `workspace_id`. Helpers in `src/functions/backend/db/bot-sessions/`: `ensureBotSession` (the active one, else a new one; `rollover` starts the next once it has `AppData.BotSessionMaxMessages`), `startBotSession`, `reopenBotSession`, `grabBotSessions` (search over title, summary and message text), `grabUserConversation` (access and membership), `grabConversationProps` (the conversation pages' props). A new session's first reply gets the previous one's summary (`grab-session-carry-over.ts`, written once with the text harness). The header's sessions icon (`(partials)/bot-sessions-modal.tsx`) lists, searches, starts and reopens sessions, and the new-session icon beside it (`(partials)/new-session-button.tsx`) starts one (`api/admin/bots/list-sessions`, `new-session`, `reopen-session`). Unread, the rail's last message and activity are per thread, from its sessions. Job results go into the job's own session, even after it closes. - **Coordinator:** the install has exactly one `kind: coordinator` bot, the super admin's alone, that coordinates the crew and delegates to every worker. It is created at signup (`bun run seed`) and, as a fallback, by the super admin's `/admin/bots`, all via `ensureCoordinatorBot` (`src/functions/backend/db/bots/ensure-coordinator-bot.ts`, idempotent). Name in `CoordinatorBotName` (`src/dict/bot-dict.ts`). Its default prompt is `CoordinatorSystemPrompt` (`src/data/coordinator-system-prompt.ts`): attached at creation, and backfilled onto a coordinator whose prompt is empty, never over an edited one. - **Routes:** `/admin/bots` ensures the coordinator and redirects the super admin to its chat (`/admin/bots/1` on a fresh install), anyone else to their latest bot, else their first workspace, else an empty page; `/admin/bots/[bot_id]` renders the conversation (header, thread and composer, the last two held to 760px; there is no right panel); `/admin/bots/[bot_id]/models` is a worker bot's own model settings, for super admins and admins (the coordinator's redirects to its chat); `/admin/workspaces/[workspace_id]` is a workspace's dashboard (`(sections)/workspace-dashboard.tsx`: read-only picture, name, description and context, latest activity, bots with their per-workspace instructions and memory, computers; its header links into `/admin/settings/workspaces/[workspace_id]`, whose `workspace-overview.tsx` has the editable groups) and `/admin/workspaces/[workspace_id]/bots/[bot_id]` is the same `Conversation` section scoped to the workspace (`pageProps.workspace`; only the coordinator and member bots). All render in the full-bleed `src/layouts/bots/` layout (rail of conversations + user popover), which `__root.tsx` uses for every `/admin/*` page. -- **Server:** `src/functions/backend/db/bots/` (`grab-bots`, `grab-bot-messages`, `grab-active-bot-task`, `grab-user-bot`, `create-bot`, `insert-user-message`). `api/admin/bots/send-message.ts` posts a user message to a bot the session user chats with (zod: `src/schemas/bot-schema.ts`), queues the bot's reply (`enqueueBotReply`) and wakes the queue (`src/bots-queue/wake-bots-queue.ts`; never import `src/bots-queue/index.ts` from a page or API route, it pulls the bunext runtime into the page bundles). `upload-attachment.ts` stores one file for the next message (private `media` row, paradigm `bot-attachment`, type from `attachmentFileKind`, documents get `text_content`, limits in `AppData.BotAttachment*`) and `send-message` takes their `media_ids` (checked to be the user's own by `grabAttachableMedia`), linked in `bot_message_media`; messages carry `attachments` (`BotMessageWithAttachments`). `mark-read.ts` clears a session's unread when a message arrives in the open conversation; `create-bot.ts` adds a worker (and puts it in a workspace when given one); `save-harness-settings.ts` saves a worker bot's models. +- **Server:** `src/functions/backend/db/bots/` (`grab-bots`, `grab-bot-messages`, `grab-active-bot-task`, `grab-user-bot`, `create-bot`, `insert-user-message`). `api/admin/bots/send-message.ts` posts a user message to a bot the session user chats with (zod: `src/schemas/bot-schema.ts`), queues the bot's reply (`enqueueBotReply`) and wakes the queue (`src/bots-queue/wake-bots-queue.ts`; never import `src/bots-queue/index.ts` from a page or API route, it pulls the bunext runtime into the page bundles). `upload-attachment.ts` stores one file for the next message (private `media` row, paradigm `bot-attachment`, type from `attachmentFileKind`, documents get `text_content`, limits in `AppData.BotAttachment*`) and `send-message` takes their `media_ids` (checked to be the user's own by `grabAttachableMedia`), linked in `bot_message_media`; messages carry `attachments` (`BotMessageWithAttachments`). `mark-read.ts` clears a session's unread when a message arrives in the open conversation; `session-messages.ts` returns a session's latest messages, which the open conversation polls every `AppData.BotReplyCatchUpMs` while a reply is due (`use-reply-catch-up`), so a reply whose live push was missed (socket down) still lands without a reload (replayed as a `server:bot-message`, so the rail and unread take it too); `create-bot.ts` adds a worker (and puts it in a workspace when given one); `save-harness-settings.ts` saves a worker bot's models. - **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 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. +- **Attachments and thinking:** the composer uploads files as they're picked, pasted or dropped (`use-composer-attachments`), shows them as chips, and sends once none is still uploading; the thread shows images as thumbnails and other files as chips. Replies read the files of the last `BotReplyAttachmentMessages` messages with any (`load-attachment-parts.ts`: images and audio as parts, documents as their text up to `BotAttachmentMaxTextChars`, videos as a note); older ones are only named. Tasks don't get the files yet. While the user has the last word in a session (every user message queues a reply, so this needs no face and shows even while the bot works in another session) or the bot waits for a model (`BotWaitingStatusLine`) or a task for its computer (`BotComputerBusyStatusLine`), `BotThinkingRow` sits under the last message; while a task runs it reads " is working…" with the task's latest step, and a reply working through tools shows its latest step under "is thinking…" (`use-reply-progress`). A coordinator with no workers gets `CoordinatorSoloNote` instead of `CoordinatorReplyNote`, so it never claims to have handed work to a worker. Live notices come from the process that ran the job at once, and from every other process through the live relay. - **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:** 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`; other tools in words, an MCP tool as "Using Exa search (web search exa)"). - **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`). diff --git a/src/bots-queue/describe-job-step.test.ts b/src/bots-queue/describe-job-step.test.ts index addc1b0..77bf41e 100644 --- a/src/bots-queue/describe-job-step.test.ts +++ b/src/bots-queue/describe-job-step.test.ts @@ -48,10 +48,21 @@ describe("describeJobStep", () => { tool_calls: [ { id: "a", name: "browser_navigate", arguments: "{}" }, { id: "b", name: "browser_click", arguments: "{}" }, - { id: "c", name: "some_mcp_tool", arguments: "{}" }, + { id: "c", name: "get_weather", arguments: "{}" }, ], })?.text, - ).toBe("Using the browser, Using some_mcp_tool"); + ).toBe("Using the browser, Using get weather"); + }); + + test("names an MCP server's tool in words", () => { + expect( + describeJobStep({ + index: 0, + text: null, + candidate, + tool_calls: [{ id: "a", name: "exa_search__web_search_exa", arguments: "{}" }], + })?.text, + ).toBe("Using Exa search (web search exa)"); }); test("keeps the first line of a text-only turn, cut to one line", () => { diff --git a/src/bots-queue/describe-job-step.ts b/src/bots-queue/describe-job-step.ts index db67c69..53e73de 100644 --- a/src/bots-queue/describe-job-step.ts +++ b/src/bots-queue/describe-job-step.ts @@ -57,5 +57,21 @@ function firstLine({ text }: { text: string | null }) { function toolLabel({ name }: { name: string }) { const prefix = Object.keys(JobStepToolPrefixLabels).find((p) => name.startsWith(p)); - return JobStepToolLabels[name] || (prefix && JobStepToolPrefixLabels[prefix]) || `Using ${name}`; + return JobStepToolLabels[name] || (prefix && JobStepToolPrefixLabels[prefix]) || `Using ${readableToolName({ name })}`; +} + +/** + * A tool name in words: `get_weather` reads "get weather", and an MCP + * server's `__` reads "Exa search (web search exa)". + */ +function readableToolName({ name }: { name: string }) { + const words = (part: string) => part.replace(/_+/g, " ").trim(); + const [server, ...rest] = name.split("__"); + const tool = words(rest.join(" ")); + + if (!tool) return words(name); + + const server_words = words(server || ""); + + return `${server_words.charAt(0).toUpperCase()}${server_words.slice(1)} (${tool})`; } diff --git a/src/data/app-data.ts b/src/data/app-data.ts index e2684a8..75cf726 100644 --- a/src/data/app-data.ts +++ b/src/data/app-data.ts @@ -213,6 +213,16 @@ export const AppData = { * bot changes from the database for its own sockets (`liveRelay`). */ LiveRelayPollMs: 1500, + /** + * While a reply is due in the open conversation, how often the page + * checks for new messages itself, in case a live push was missed (the + * socket was down) and the thinking row would otherwise stay up. + */ + BotReplyCatchUpMs: 5000, + /** + * How many of a session's latest messages that check reads. + */ + BotReplyCatchUpMessages: 20, /** * How much of the conversation a bot sees when it replies. */ diff --git a/src/pages/admin/bots/[bot_id]/(functions)/post-session-api.ts b/src/pages/admin/bots/[bot_id]/(functions)/post-session-api.ts index bf964fb..c5c4c18 100644 --- a/src/pages/admin/bots/[bot_id]/(functions)/post-session-api.ts +++ b/src/pages/admin/bots/[bot_id]/(functions)/post-session-api.ts @@ -5,7 +5,7 @@ type Params = { /** * Under `/api/admin/bots/`. */ - path: "list-sessions" | "new-session" | "reopen-session"; + path: "list-sessions" | "new-session" | "reopen-session" | "session-messages"; body: Record; }; diff --git a/src/pages/admin/bots/[bot_id]/(hooks)/use-reply-catch-up.ts b/src/pages/admin/bots/[bot_id]/(hooks)/use-reply-catch-up.ts new file mode 100644 index 0000000..8fd67d7 --- /dev/null +++ b/src/pages/admin/bots/[bot_id]/(hooks)/use-reply-catch-up.ts @@ -0,0 +1,60 @@ +import { useEffect, useRef } from "react"; +import { AppData } from "@/src/data/app-data"; +import type { BotMessageWithAttachments, WebSocketDataType } from "@/src/types"; +import postSessionApi from "../(functions)/post-session-api"; + +type Params = { + session_id?: number | null; + /** + * The thread's last message is the user's, so a reply is due. + */ + awaiting_reply: boolean; + /** + * The thread's last message: only newer ones are replayed. + */ + last_message_id?: number | null; +}; + +/** + * While a reply is due, checks the session's latest messages every + * `AppData.BotReplyCatchUpMs`. Replies arrive live; this only catches one + * whose push was missed (the socket was down at the time), so the + * thinking row can't stay up until a reload. A missed message is replayed + * as the `server:bot-message` it would have been, so the thread, the rail + * and the unread flag all take it the usual way. + */ +export default function useReplyCatchUp({ session_id, awaiting_reply, last_message_id }: Params) { + const last_message = useRef(Number(last_message_id) || 0); + + last_message.current = Number(last_message_id) || 0; + + useEffect(() => { + if (!awaiting_reply || !session_id) return; + + let stale = false; + + const timer = setInterval(async () => { + const res = await postSessionApi({ + path: "session-messages", + body: { session_id }, + }).catch(() => null); + + if (stale || !res?.success || !Array.isArray(res.payload)) return; + + const missed = (res.payload as BotMessageWithAttachments[]).filter( + (message) => Number(message.id) > last_message.current, + ); + + for (const message of missed) { + const data: WebSocketDataType = { event: "server:bot-message", data: message }; + + window.dispatchEvent(new CustomEvent("wsDataEvent", { detail: { data, message: data } })); + } + }, AppData.BotReplyCatchUpMs); + + return () => { + stale = true; + clearInterval(timer); + }; + }, [awaiting_reply, session_id]); +} diff --git a/src/pages/admin/bots/[bot_id]/(hooks)/use-reply-progress.ts b/src/pages/admin/bots/[bot_id]/(hooks)/use-reply-progress.ts new file mode 100644 index 0000000..1b2bf41 --- /dev/null +++ b/src/pages/admin/bots/[bot_id]/(hooks)/use-reply-progress.ts @@ -0,0 +1,53 @@ +import { useRef, useState } from "react"; +import useServerEvent from "@/src/hooks/use-server-event"; +import type { BotJobUpdateData } from "@/src/types"; + +type Params = { + bot_id: number; + /** + * The open session: steps of replies in other sessions are left out. + */ + session_id?: number | null; + /** + * The thread's last message. A step belongs to the reply due after it, + * so a new message (the reply itself, or another from the user) hides + * it. + */ + last_message_id?: number | null; +}; + +type Step = { + text: string; + after_message_id: number | null; +}; + +/** + * The latest step of the reply running in the open session (a + * `bots_queue_updates` row's text, from `server:bot-job-update` without a + * task), so a reply that works through tools shows what it's doing. Only + * tool steps: the closing note is the answer itself. + */ +export default function useReplyProgress({ bot_id, session_id, last_message_id }: Params) { + const [step, setStep] = useState(null); + const last_message = useRef(last_message_id || null); + + last_message.current = last_message_id || null; + + useServerEvent({ + event: "server:bot-job-update", + onData: (update: BotJobUpdateData) => { + if (update.bot_id != bot_id || update.task_id || !update.text) return; + + /** + * A turn without tools ends the reply: its text is the answer, + * about to land as the message. + */ + if (update.kind == "note") return; + if (!session_id || update.session_id != session_id) return; + + setStep({ text: update.text, after_message_id: last_message.current }); + }, + }); + + return step && step.after_message_id == (last_message_id || null) ? step.text : 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 17c505d..edd3f4c 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 @@ -16,6 +16,10 @@ type Props = { * The latest step of the bot's active task. */ progress?: string | null; + /** + * The latest step of the reply running in this conversation. + */ + reply_progress?: string | null; /** * The thread's last message is the user's, so a reply is due here. */ @@ -29,13 +33,13 @@ type Props = { /** * Under the last message while the bot is working on a reply or a 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, + * none. A task or a reply 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) { +export default function BotThinkingRow({ bot, progress, reply_progress, awaiting_reply, has_task }: Props) { const name = bot.name || "The bot"; const asleep = bot.face == "asleep"; const waiting_model = asleep && bot.status_line == BotWaitingStatusLine && Boolean(awaiting_reply || has_task); @@ -53,7 +57,7 @@ export default function BotThinkingRow({ bot, progress, awaiting_reply, has_task : working ? `${name} is working…` : `${bot.status_line}…`; - const detail = working ? progress : null; + const detail = working ? progress : thinking ? reply_progress : null; return ( diff --git a/src/pages/admin/bots/[bot_id]/(partials)/message-thread.tsx b/src/pages/admin/bots/[bot_id]/(partials)/message-thread.tsx index bf881fc..0a6ae3e 100644 --- a/src/pages/admin/bots/[bot_id]/(partials)/message-thread.tsx +++ b/src/pages/admin/bots/[bot_id]/(partials)/message-thread.tsx @@ -14,6 +14,10 @@ type Props = { * The latest step of the bot's active task, shown while it works. */ progress?: string | null; + /** + * The latest step of the reply running here, shown while it thinks. + */ + reply_progress?: string | null; /** * This session has an active task. */ @@ -26,7 +30,7 @@ type Props = { * While the bot works on a reply or a task, a status row sits under the * last one. */ -export default function MessageThread({ bot, messages, progress, has_task }: Props) { +export default function MessageThread({ bot, messages, progress, reply_progress, has_task }: Props) { const groups = groupMessages({ messages }); return ( @@ -47,6 +51,7 @@ export default function MessageThread({ bot, messages, progress, has_task }: Pro diff --git a/src/pages/admin/bots/[bot_id]/(sections)/conversation.tsx b/src/pages/admin/bots/[bot_id]/(sections)/conversation.tsx index 085962e..73b824c 100644 --- a/src/pages/admin/bots/[bot_id]/(sections)/conversation.tsx +++ b/src/pages/admin/bots/[bot_id]/(sections)/conversation.tsx @@ -8,6 +8,8 @@ import useServerEvent from "@/src/hooks/use-server-event"; import { BotComputersContext } from "../(data)/bot-computers-context"; import useBotComputers from "../(hooks)/use-bot-computers"; import useTaskProgress from "../(hooks)/use-task-progress"; +import useReplyProgress from "../(hooks)/use-reply-progress"; +import useReplyCatchUp from "../(hooks)/use-reply-catch-up"; import ConversationHeader from "../(partials)/conversation-header"; import MessageThread from "../(partials)/message-thread"; import Composer from "../(partials)/composer"; @@ -51,6 +53,20 @@ export default function Conversation() { task: pageProps?.bot_task || null, }); + const reply_progress = useReplyProgress({ + bot_id: bot?.id || 0, + session_id, + last_message_id: messages[messages.length - 1]?.id || null, + }); + + const awaiting_reply = messages[messages.length - 1]?.author == "user"; + + useReplyCatchUp({ + session_id, + awaiting_reply, + last_message_id: messages[messages.length - 1]?.id || null, + }); + /** * A message that starts a newer session (sent here or in another tab) * moves the thread onto it. @@ -107,6 +123,7 @@ export default function Conversation() { bot={live_bot} messages={messages} progress={progress.text} + reply_progress={reply_progress} has_task={Boolean(progress.task_id)} /> +> = async ({ req, body }) => { + try { + if (req.method != "POST") { + throw new AppError(`Method not allowed`); + } + + const { user } = await userAuth({ req }); + + if (!user?.logged_in_status || !user.id) { + return { success: false, msg: `Unauthorized`, logoutUser: true }; + } + + const { session_id } = parseWithSchema({ schema: BotSessionIdRequestSchema, data: body }); + + const session = await grabUserBotSession({ session_id, user_id: user.id }); + + if (!session?.id) { + throw new AppError(`Session not found.`); + } + + return { + success: true, + payload: await grabBotMessages({ + session_id: session.id, + limit: AppData.BotReplyCatchUpMessages, + }), + }; + } catch (error: unknown) { + return { success: false, msg: safeErrorMessage({ error }) }; + } +};