Add replies and emoji reactions to chat messages

Every message has a row under it to reply or react. Replying quotes the
message above the composer, and the sent message stores
reply_to_message_id (a plain id, not a foreign key, so deleting the
quoted message only drops the quote). send-message accepts a quote from
any session of the same conversation, so a rollover or a thread that
moved on still sends; the conversation drops a picked quote when its
session changes. The bot reads the quote as "[Replying to ...]" before
the user's turn, file-only replies included, and relayed messages carry
it too.

Reactions are a fixed set (BotMessageReactions) in
bot_message_reactions, one per message, emoji and user or bot. Users
toggle their own through api/admin/bots/react-message, and every bot
gets react_to_message for the session it answers (the user's latest
message by default). Both push the fresh list as server:bot-reaction,
with mine worked out for the session's user.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-19 21:56:31 +01:00
co-authored by Claude Opus 5.5
parent bc499a7466
commit d1b5b6d68a
44 changed files with 1289 additions and 58 deletions
+1
View File
@@ -92,6 +92,7 @@ Inspect live data with `bun llm-code-assistant/query-db.ts "<sql>"` - 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. - **Routes:** `/admin/bots` ensures the coordinator and redirects the super admin to its chat (`/admin/bots/1` on a fresh install), anyone else to their latest bot, else their first workspace, else an empty page; `/admin/bots/[bot_id]` renders the conversation (header, thread and composer, the last two held to 760px; there is no right panel); `/admin/bots/[bot_id]/models` is a worker bot's own model settings, for super admins and admins (the coordinator's redirects to its chat); `/admin/workspaces/[workspace_id]` is a workspace's dashboard (`(sections)/workspace-dashboard.tsx`: read-only picture, name, description and context, latest activity, bots with their per-workspace instructions and memory, computers; its header links into `/admin/settings/workspaces/[workspace_id]`, whose `workspace-overview.tsx` has the editable groups) and `/admin/workspaces/[workspace_id]/bots/[bot_id]` is the same `Conversation` section scoped to the workspace (`pageProps.workspace`; only the coordinator and member bots). All render in the full-bleed `src/layouts/bots/` layout (rail of conversations + user popover), which `__root.tsx` uses for every `/admin/*` page.
- **Server:** `src/functions/backend/db/bots/` (`grab-bots`, `grab-bot-messages`, `grab-active-bot-task`, `grab-user-bot`, `create-bot`, `insert-user-message`). `api/admin/bots/send-message.ts` posts a user message to a bot the session user chats with (zod: `src/schemas/bot-schema.ts`), queues the bot's reply (`enqueueBotReply`) and wakes the queue (`src/bots-queue/wake-bots-queue.ts`; never import `src/bots-queue/index.ts` from a page or API route, it pulls the bunext runtime into the page bundles). `upload-attachment.ts` stores one file for the next message (private `media` row, paradigm `bot-attachment`, type from `attachmentFileKind`, documents get `text_content`, limits in `AppData.BotAttachment*`) and `send-message` takes their `media_ids` (checked to be the user's own by `grabAttachableMedia`), linked in `bot_message_media`; messages carry `attachments` (`BotMessageWithAttachments`). `mark-read.ts` clears a session's unread when a message arrives in the open conversation; `session-messages.ts` returns a session's latest messages, which the open conversation polls every `AppData.BotReplyCatchUpMs` while a reply is due (`use-reply-catch-up`), so a reply whose live push was missed (socket down) still lands without a reload (replayed as a `server:bot-message`, so the rail and unread take it too); `create-bot.ts` adds a worker (and puts it in a workspace when given one); `save-harness-settings.ts` saves a worker bot's models. - **Server:** `src/functions/backend/db/bots/` (`grab-bots`, `grab-bot-messages`, `grab-active-bot-task`, `grab-user-bot`, `create-bot`, `insert-user-message`). `api/admin/bots/send-message.ts` posts a user message to a bot the session user chats with (zod: `src/schemas/bot-schema.ts`), queues the bot's reply (`enqueueBotReply`) and wakes the queue (`src/bots-queue/wake-bots-queue.ts`; never import `src/bots-queue/index.ts` from a page or API route, it pulls the bunext runtime into the page bundles). `upload-attachment.ts` stores one file for the next message (private `media` row, paradigm `bot-attachment`, type from `attachmentFileKind`, documents get `text_content`, limits in `AppData.BotAttachment*`) and `send-message` takes their `media_ids` (checked to be the user's own by `grabAttachableMedia`), linked in `bot_message_media`; messages carry `attachments` (`BotMessageWithAttachments`). `mark-read.ts` clears a session's unread when a message arrives in the open conversation; `session-messages.ts` returns a session's latest messages, which the open conversation polls every `AppData.BotReplyCatchUpMs` while a reply is due (`use-reply-catch-up`), so a reply whose live push was missed (socket down) still lands without a reload (replayed as a `server:bot-message`, so the rail and unread take it too); `create-bot.ts` adds a worker (and puts it in a workspace when given one); `save-harness-settings.ts` saves a worker bot's models.
- **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.
- **Replies and reactions:** every message has a row under it (`(partials)/message-actions.tsx`, handlers from `BotMessageActionsContext`) to reply or react. Replying quotes the message above the composer (`composer-reply-preview.tsx`) and the sent one carries `bot_messages.reply_to_message_id` (not a foreign key: a deleted quote only drops it); `send-message` takes any message of the same conversation (the user's, same bot and workspace, any session, so a rollover or a moved-on thread still sends), and the conversation drops a picked quote when its session changes. Messages carry `reply_to` (`grabMessagesReplies`, also on relayed messages), shown by `message-reply-quote.tsx`, and the bot reads it as `[Replying to ...]` before the user's turn (`botMessagesToHarness`). Reactions are a fixed set (`BotMessageReactions` in `src/dict/bot-dict.ts`) in `bot_message_reactions` (one row per message, emoji and user, or bot); `api/admin/bots/react-message.ts` toggles the user's own on a message in their sessions (`grabUserBotMessage`, `toggleMessageReaction`), and every bot gets `react_to_message` (`src/harness/tools/bot/react-to-message-tool.ts`, chat replies and tasks: a message in the session it answers, the user's latest by default). Both push the message's fresh reactions (`grabMessagesReactions`, `mine` for the session user) to the session's user as `server:bot-reaction` (`BotMessageReactionData`), which replaces the message's own; `grabBotMessages` adds them when given `user_id`. Reactions aren't relayed between processes (production runs one).
- **Attachments and thinking:** the composer uploads files as they're picked, pasted or dropped (`use-composer-attachments`), shows them as chips, and sends once none is still uploading; the thread shows images as thumbnails and other files as chips. Replies read the files of the last `BotReplyAttachmentMessages` messages with any (`load-attachment-parts.ts`: images and audio as parts, documents as their text up to `BotAttachmentMaxTextChars`, videos as a note); older ones are only named. Tasks don't get the files yet. While the user has the last word in a session (every user message queues a reply, so this needs no face and shows even while the bot works in another session) or the bot waits for a model (`BotWaitingStatusLine`) or a task for its computer (`BotComputerBusyStatusLine`), `BotThinkingRow` sits under the last message; while a task runs it reads "<name> is working…" with the task's latest step, and a reply working through tools shows its latest step under "is thinking…" (`use-reply-progress`). A coordinator with no workers gets `CoordinatorSoloNote` instead of `CoordinatorReplyNote`, so it never claims to have handed work to a worker. Live notices come from the process that ran the job at once, and from every other process through the live relay. - **Attachments and thinking:** the composer uploads files as they're picked, pasted or dropped (`use-composer-attachments`), shows them as chips, and sends once none is still uploading; the thread shows images as thumbnails and other files as chips. Replies read the files of the last `BotReplyAttachmentMessages` messages with any (`load-attachment-parts.ts`: images and audio as parts, documents as their text up to `BotAttachmentMaxTextChars`, videos as a note); older ones are only named. Tasks don't get the files yet. While the user has the last word in a session (every user message queues a reply, so this needs no face and shows even while the bot works in another session) or the bot waits for a model (`BotWaitingStatusLine`) or a task for its computer (`BotComputerBusyStatusLine`), `BotThinkingRow` sits under the last message; while a task runs it reads "<name> is working…" with the task's latest step, and a reply working through tools shows its latest step under "is thinking…" (`use-reply-progress`). A coordinator with no workers gets `CoordinatorSoloNote` instead of `CoordinatorReplyNote`, so it never claims to have handed work to a worker. Live notices come from the process that ran the job at once, and from every other process through the live relay.
- **Voice:** the composer's mic (`(partials)/composer-voice-button.tsx`, `use-voice-recorder`, shown once the browser can record, disabled with a note while transcription has no model to run on for the bot there, `bot_voice` page prop, from `resolveHarnessCandidates`) dictates: the recording goes to `api/admin/bots/transcribe.ts` (`transcribeAudio` with the bot's models, after `grabUserConversation`) and the text lands in the input at the cursor, to edit and send; Escape cancels, `AppData.ComposerVoiceMaxMs` stops it. Each bot message has a play button (disabled with a note while speech has no model) (`speak-message-button.tsx`, one player per conversation via `SpeakMessageContext` / `use-speak-message`) that plays `GET api/admin/bots/speak-message.ts?message_id=` straight in an `<audio>` (`speakText` streamed through as the provider makes it, so it starts in a couple of seconds; a failure answers in JSON, which the hook fetches again to show; only the user's own sessions, `grabSpeakableMessage`; text from `speechText`, cut at `AppData.SpeechMaxChars`). Under Speech, every model settings form has "Hear a sample" (`settings/models/(partials)/harness-voice-preview.tsx`), which plays `AppData.VoicePreviewText` in the form's model and voice before they're saved (`GET api/admin/harness/preview-voice`, super admins and admins, that model only via `grabModelCandidate`, streamed). Neither recordings nor audio are stored. Harness errors (`HarnessError`, safe to show) come back as the buttons' titles. - **Voice:** the composer's mic (`(partials)/composer-voice-button.tsx`, `use-voice-recorder`, shown once the browser can record, disabled with a note while transcription has no model to run on for the bot there, `bot_voice` page prop, from `resolveHarnessCandidates`) dictates: the recording goes to `api/admin/bots/transcribe.ts` (`transcribeAudio` with the bot's models, after `grabUserConversation`) and the text lands in the input at the cursor, to edit and send; Escape cancels, `AppData.ComposerVoiceMaxMs` stops it. Each bot message has a play button (disabled with a note while speech has no model) (`speak-message-button.tsx`, one player per conversation via `SpeakMessageContext` / `use-speak-message`) that plays `GET api/admin/bots/speak-message.ts?message_id=` straight in an `<audio>` (`speakText` streamed through as the provider makes it, so it starts in a couple of seconds; a failure answers in JSON, which the hook fetches again to show; only the user's own sessions, `grabSpeakableMessage`; text from `speechText`, cut at `AppData.SpeechMaxChars`). Under Speech, every model settings form has "Hear a sample" (`settings/models/(partials)/harness-voice-preview.tsx`), which plays `AppData.VoicePreviewText` in the form's model and voice before they're saved (`GET api/admin/harness/preview-voice`, super admins and admins, that model only via `grabModelCandidate`, streamed). Neither recordings nor audio are stored. Harness errors (`HarnessError`, safe to show) come back as the buttons' titles.
- **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). - **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).
+81
View File
@@ -411,6 +411,11 @@ const schema: BUN_SQLITE_DatabaseSchemaType = {
options: [0, 1], options: [0, 1],
defaultValue: 0, defaultValue: 0,
}, },
{
fieldName: "reply_to_message_id",
fieldDescription: `The message this one replies to, quoted above it. Not a foreign key, so deleting the quoted message only drops the quote`,
dataType: "INTEGER",
},
], ],
indexes: [ indexes: [
{ {
@@ -421,6 +426,82 @@ const schema: BUN_SQLITE_DatabaseSchemaType = {
indexName: "idx_bot_messages_session_id_created_at", indexName: "idx_bot_messages_session_id_created_at",
indexTableFields: ["session_id", "created_at"], indexTableFields: ["session_id", "created_at"],
}, },
{
indexName: "idx_bot_messages_reply_to_message_id",
indexTableFields: ["reply_to_message_id"],
},
],
},
{
tableName: "bot_message_reactions",
tableDescription: `Emoji reactions on chat messages, one per user and emoji`,
fields: [
{
fieldName: "message_id",
dataType: "INTEGER",
foreignKey: {
cascadeDelete: true,
destinationTableName: "bot_messages",
destinationTableColumnName: "id",
destinationTableColumnType: "INTEGER",
foreignKeyName: "bot_message_reaction_message_fk",
},
},
{
fieldName: "user_id",
fieldDescription: `The user who reacted. Null for a bot's own reaction`,
dataType: "INTEGER",
foreignKey: {
cascadeDelete: true,
destinationTableName: "users",
destinationTableColumnName: "id",
destinationTableColumnType: "INTEGER",
foreignKeyName: "bot_message_reaction_user_fk",
},
},
{
fieldName: "bot_id",
fieldDescription: `The bot that reacted. Null for a user's reaction`,
dataType: "INTEGER",
foreignKey: {
cascadeDelete: true,
destinationTableName: "bots",
destinationTableColumnName: "id",
destinationTableColumnType: "INTEGER",
foreignKeyName: "bot_message_reaction_bot_fk",
},
},
{
fieldName: "emoji",
fieldDescription: `One of BotMessageReactions`,
dataType: "TEXT",
},
],
uniqueConstraints: [
{
alias: "one_reaction_per_message_user_emoji",
constraintName: "A user reacts to a message once with the same emoji",
constraintTableFields: [
{ value: "message_id" },
{ value: "user_id" },
{ value: "emoji" },
],
},
{
alias: "one_reaction_per_message_bot_emoji",
constraintName: "A bot reacts to a message once with the same emoji",
constraintTableFields: [
{ value: "message_id" },
{ value: "bot_id" },
{ value: "emoji" },
],
},
],
indexes: [
{
indexName: "idx_bot_message_reactions_message_id",
indexTableFields: ["message_id"],
},
], ],
}, },
{ {
+34 -1
View File
@@ -8,6 +8,7 @@ export const BunSQLiteTables = [
"bots", "bots",
"bot_sessions", "bot_sessions",
"bot_messages", "bot_messages",
"bot_message_reactions",
"bot_tasks", "bot_tasks",
"bot_task_steps", "bot_task_steps",
"bots_queue", "bots_queue",
@@ -286,6 +287,38 @@ export type BUN_SQLITE_COBBLE_BOT_MESSAGES = {
* Renders the Needs you chip under the message * Renders the Needs you chip under the message
*/ */
needs_you?: 0 | 1 | ""; needs_you?: 0 | 1 | "";
/**
* The message this one replies to, quoted above it. Not a foreign key, so deleting the quoted message only drops the quote
*/
reply_to_message_id?: number | "";
}
export type BUN_SQLITE_COBBLE_BOT_MESSAGE_REACTIONS = {
/**
* The unique identifier of the record.
*/
id?: number | "";
/**
* The time when the record was created. (Unix Timestamp)
*/
created_at?: number | "";
/**
* The time when the record was updated. (Unix Timestamp)
*/
updated_at?: number | "";
message_id?: number | "";
/**
* The user who reacted. Null for a bot's own reaction
*/
user_id?: number | "";
/**
* The bot that reacted. Null for a user's reaction
*/
bot_id?: number | "";
/**
* One of BotMessageReactions
*/
emoji?: string;
} }
export type BUN_SQLITE_COBBLE_BOT_TASKS = { export type BUN_SQLITE_COBBLE_BOT_TASKS = {
@@ -1385,4 +1418,4 @@ export type BUN_SQLITE_COBBLE_ENVS = {
expose_to_computers?: 0 | 1 | ""; expose_to_computers?: 0 | 1 | "";
} }
export type BUN_SQLITE_COBBLE_ALL_TYPEDEFS = BUN_SQLITE_COBBLE_USERS & BUN_SQLITE_COBBLE_USER_TYPES & BUN_SQLITE_COBBLE_SSO_LOGIN_CODES & BUN_SQLITE_COBBLE_USER_PASSWORDS & BUN_SQLITE_COBBLE_MEDIA & BUN_SQLITE_COBBLE_MEDIA_PARADIGMS & BUN_SQLITE_COBBLE_BOTS & BUN_SQLITE_COBBLE_BOT_SESSIONS & BUN_SQLITE_COBBLE_BOT_MESSAGES & BUN_SQLITE_COBBLE_BOT_TASKS & BUN_SQLITE_COBBLE_BOT_TASK_STEPS & BUN_SQLITE_COBBLE_BOTS_QUEUE & BUN_SQLITE_COBBLE_BOTS_QUEUE_UPDATES & BUN_SQLITE_COBBLE_CRON_JOBS & BUN_SQLITE_COBBLE_WORKSPACES & BUN_SQLITE_COBBLE_WORKSPACE_BOTS & BUN_SQLITE_COBBLE_WORKSPACE_USERS & BUN_SQLITE_COBBLE_BOT_USERS & BUN_SQLITE_COBBLE_BOT_USER_MEMORIES & BUN_SQLITE_COBBLE_BOT_USER_FACES & BUN_SQLITE_COBBLE_BOT_MESSAGE_MEDIA & BUN_SQLITE_COBBLE_CONTAINERS & BUN_SQLITE_COBBLE_COMPUTERS & BUN_SQLITE_COBBLE_BOT_COMPUTERS & BUN_SQLITE_COBBLE_WORKSPACE_COMPUTERS & BUN_SQLITE_COBBLE_HARNESS_PROVIDERS & BUN_SQLITE_COBBLE_HARNESS_MODELS & BUN_SQLITE_COBBLE_HARNESS_SETTINGS & BUN_SQLITE_COBBLE_BOT_HARNESS_SETTINGS & BUN_SQLITE_COBBLE_WORKSPACE_HARNESS_SETTINGS & BUN_SQLITE_COBBLE_MCP_SERVERS & BUN_SQLITE_COBBLE_CUSTOM_TOOLS & BUN_SQLITE_COBBLE_BOT_MCP_SERVERS & BUN_SQLITE_COBBLE_WORKSPACE_MCP_SERVERS & BUN_SQLITE_COBBLE_BOT_CUSTOM_TOOLS & BUN_SQLITE_COBBLE_WORKSPACE_CUSTOM_TOOLS & BUN_SQLITE_COBBLE_LOGS & BUN_SQLITE_COBBLE_USER_PRESENCE & BUN_SQLITE_COBBLE_PUSH_SUBSCRIPTIONS & BUN_SQLITE_COBBLE_ENVS export type BUN_SQLITE_COBBLE_ALL_TYPEDEFS = BUN_SQLITE_COBBLE_USERS & BUN_SQLITE_COBBLE_USER_TYPES & BUN_SQLITE_COBBLE_SSO_LOGIN_CODES & BUN_SQLITE_COBBLE_USER_PASSWORDS & BUN_SQLITE_COBBLE_MEDIA & BUN_SQLITE_COBBLE_MEDIA_PARADIGMS & BUN_SQLITE_COBBLE_BOTS & BUN_SQLITE_COBBLE_BOT_SESSIONS & BUN_SQLITE_COBBLE_BOT_MESSAGES & BUN_SQLITE_COBBLE_BOT_MESSAGE_REACTIONS & BUN_SQLITE_COBBLE_BOT_TASKS & BUN_SQLITE_COBBLE_BOT_TASK_STEPS & BUN_SQLITE_COBBLE_BOTS_QUEUE & BUN_SQLITE_COBBLE_BOTS_QUEUE_UPDATES & BUN_SQLITE_COBBLE_CRON_JOBS & BUN_SQLITE_COBBLE_WORKSPACES & BUN_SQLITE_COBBLE_WORKSPACE_BOTS & BUN_SQLITE_COBBLE_WORKSPACE_USERS & BUN_SQLITE_COBBLE_BOT_USERS & BUN_SQLITE_COBBLE_BOT_USER_MEMORIES & BUN_SQLITE_COBBLE_BOT_USER_FACES & BUN_SQLITE_COBBLE_BOT_MESSAGE_MEDIA & BUN_SQLITE_COBBLE_CONTAINERS & BUN_SQLITE_COBBLE_COMPUTERS & BUN_SQLITE_COBBLE_BOT_COMPUTERS & BUN_SQLITE_COBBLE_WORKSPACE_COMPUTERS & BUN_SQLITE_COBBLE_HARNESS_PROVIDERS & BUN_SQLITE_COBBLE_HARNESS_MODELS & BUN_SQLITE_COBBLE_HARNESS_SETTINGS & BUN_SQLITE_COBBLE_BOT_HARNESS_SETTINGS & BUN_SQLITE_COBBLE_WORKSPACE_HARNESS_SETTINGS & BUN_SQLITE_COBBLE_MCP_SERVERS & BUN_SQLITE_COBBLE_CUSTOM_TOOLS & BUN_SQLITE_COBBLE_BOT_MCP_SERVERS & BUN_SQLITE_COBBLE_WORKSPACE_MCP_SERVERS & BUN_SQLITE_COBBLE_BOT_CUSTOM_TOOLS & BUN_SQLITE_COBBLE_WORKSPACE_CUSTOM_TOOLS & BUN_SQLITE_COBBLE_LOGS & BUN_SQLITE_COBBLE_USER_PRESENCE & BUN_SQLITE_COBBLE_PUSH_SUBSCRIPTIONS & BUN_SQLITE_COBBLE_ENVS
@@ -36,6 +36,73 @@ describe("botMessagesToHarness", () => {
}); });
}); });
describe("botMessagesToHarness with replies", () => {
test("quotes what a user message answers", () => {
expect(
botMessagesToHarness({
messages: [
{ author: "user", text: "hi" },
{ author: "bot", text: "The report is ready." },
{
author: "user",
text: "Great, send it over.",
reply_to: { id: 4, author: "bot", text: "The report is ready." },
},
],
}),
).toEqual([
{ role: "user", content: "hi" },
{ role: "assistant", content: "The report is ready." },
{
role: "user",
content: `[Replying to your earlier message: "The report is ready."]\n\nGreat, send it over.`,
},
]);
});
test("says a quoted file-only message was a file", () => {
expect(
botMessagesToHarness({
messages: [
{
author: "user",
text: "What was that?",
reply_to: { id: 5, author: "user", text: "" },
},
],
}),
).toEqual([
{
role: "user",
content: `[Replying to the user's own earlier message: "(a file)"]\n\nWhat was that?`,
},
]);
});
});
describe("botMessagesToHarness with a file-only reply", () => {
test("keeps the quote when the reply has no text", () => {
expect(
botMessagesToHarness({
messages: [
{
id: 7,
author: "user",
text: "",
reply_to: { id: 4, author: "bot", text: "Send me the logo." },
attachments: [{ id: 9, name: "logo.png", media_type: "image", url: "/media/private/9" }],
},
],
}),
).toEqual([
{
role: "user",
content: `[Replying to your earlier message: "Send me the logo."]\n\n[Attached earlier: "logo.png"]`,
},
]);
});
});
describe("botMessagesToHarness with files", () => { describe("botMessagesToHarness with files", () => {
const attachments = [{ id: 9, name: "shot.png", media_type: "image" as const, url: "/media/private/9" }]; const attachments = [{ id: 9, name: "shot.png", media_type: "image" as const, url: "/media/private/9" }];
+24 -2
View File
@@ -2,7 +2,10 @@ import type { HarnessContentPart, HarnessMessage } from "@/src/harness/types";
import type { BotMessageWithAttachments } from "@/src/types"; import type { BotMessageWithAttachments } from "@/src/types";
type Params = { type Params = {
messages: Pick<BotMessageWithAttachments, "id" | "author" | "text" | "attachments">[]; messages: Pick<
BotMessageWithAttachments,
"id" | "author" | "text" | "attachments" | "reply_to"
>[];
/** /**
* Loaded files by message id (`loadAttachmentParts`). Files of other * Loaded files by message id (`loadAttachmentParts`). Files of other
* messages are only named. * messages are only named.
@@ -61,7 +64,7 @@ function messageParts({
role: Turn["role"]; role: Turn["role"];
parts?: Params["parts"]; parts?: Params["parts"];
}): HarnessContentPart[] { }): HarnessContentPart[] {
const text = message.text?.trim(); const text = replyContext({ message, text: message.text?.trim() || "" });
const content: HarnessContentPart[] = text ? [{ type: "text", text }] : []; const content: HarnessContentPart[] = text ? [{ type: "text", text }] : [];
if (role == "assistant" || !message.attachments?.length) return content; if (role == "assistant" || !message.attachments?.length) return content;
@@ -79,6 +82,25 @@ function messageParts({
]; ];
} }
/**
* Names the message a user turn answers, so the bot reads the reply in
* context even when the quoted message is further back. A reply with
* only files still gets the quote.
*/
function replyContext({
message,
text,
}: {
message: Params["messages"][number];
text: string;
}) {
if (message.author != "user" || !message.reply_to) return text;
const quote = message.reply_to.text.trim().slice(0, 500) || "(a file)";
return `[Replying to ${message.reply_to.author == "user" ? "the user's own earlier message" : "your earlier message"}: "${quote}"]${text ? `\n\n${text}` : ""}`;
}
/** /**
* Joins neighbouring text parts with a blank line. * Joins neighbouring text parts with a blank line.
*/ */
+1 -1
View File
@@ -87,7 +87,7 @@ export default async function runBotTask({
]; ];
const tools = [ const tools = [
...grabBotTools({ user_id, bot_id: bot.id, workspace_id, computers }), ...grabBotTools({ user_id, bot_id: bot.id, workspace_id, computers, session_id }),
...(bot.kind == "coordinator" ...(bot.kind == "coordinator"
? [ ? [
createWorkspaceTool({ user_id, workspaces: coordinator_workspaces }), createWorkspaceTool({ user_id, workspaces: coordinator_workspaces }),
+33
View File
@@ -95,6 +95,39 @@ export const BotMessageAuthors = [
export type BotMessageAuthor = (typeof BotMessageAuthors)[number]["value"]; export type BotMessageAuthor = (typeof BotMessageAuthors)[number]["value"];
/**
* The emoji a message can be reacted with. Kept small and fixed, so the
* picker stays a row and every reaction reads the same everywhere.
*/
export const BotMessageReactions = [
{
title: "Like",
value: "👍",
},
{
title: "Love",
value: "❤️",
},
{
title: "Laugh",
value: "😂",
},
{
title: "Surprised",
value: "😮",
},
{
title: "Sad",
value: "😢",
},
{
title: "Celebrate",
value: "🎉",
},
] as const;
export type BotMessageReactionEmoji = (typeof BotMessageReactions)[number]["value"];
export const BotTaskStatuses = [ export const BotTaskStatuses = [
{ {
title: "Active", title: "Active",
@@ -67,7 +67,7 @@ export default async function grabConversationProps({
* shows its pick. * shows its pick.
*/ */
const [bot_messages, bot_harness_overview, bot_computers, bot_task, bot_extensions, transcription, speech] = await Promise.all([ const [bot_messages, bot_harness_overview, bot_computers, bot_task, bot_extensions, transcription, speech] = await Promise.all([
grabBotMessages({ session_id: session.id! }), grabBotMessages({ session_id: session.id!, user_id }),
(bot.kind == "coordinator" && !scope (bot.kind == "coordinator" && !scope
? grabHarnessOverview().then(({ harnesses }) => ({ harnesses })) ? grabHarnessOverview().then(({ harnesses }) => ({ harnesses }))
: grabBotHarnessOverview({ : grabBotHarnessOverview({
@@ -0,0 +1,31 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type { BotMessageReactionEmoji } from "@/src/dict/bot-dict";
import grabMessagesReactions from "./grab-messages-reactions";
type Params = {
message_id: number;
bot_id: number | string;
/**
* The user whose conversation it is, so the returned list marks their
* own reactions.
*/
user_id: number | string;
emoji: BotMessageReactionEmoji;
};
/**
* Adds a bot's reaction to a message (once per emoji) and returns the
* message's fresh reactions.
*/
export default async function addBotMessageReaction({ message_id, bot_id, user_id, emoji }: Params) {
const now = Date.now();
await BunSQLite.sql({
sql: `INSERT OR IGNORE INTO bot_message_reactions (created_at, updated_at, message_id, bot_id, emoji) VALUES (?, ?, ?, ?, ?)`,
values: [now, now, message_id, bot_id, emoji],
});
return (await grabMessagesReactions({ message_ids: [message_id], user_id })).get(
message_id,
) || [];
}
@@ -2,19 +2,28 @@ import type { BUN_SQLITE_COBBLE_BOT_MESSAGES } from "@/db/types/db";
import type { BotMessageWithAttachments } from "@/src/types"; import type { BotMessageWithAttachments } from "@/src/types";
import BunSQLite from "@moduletrace/bun-sqlite"; import BunSQLite from "@moduletrace/bun-sqlite";
import grabMessagesMedia from "./grab-messages-media"; import grabMessagesMedia from "./grab-messages-media";
import grabMessagesReactions from "./grab-messages-reactions";
import grabMessagesReplies from "./grab-messages-replies";
import mediaToAttachment from "./media-to-attachment"; import mediaToAttachment from "./media-to-attachment";
type Params = { type Params = {
session_id: number | string; session_id: number | string;
limit?: number; limit?: number;
/**
* Whose conversation it is, so a reaction can show as the user's own.
* Left out for the bot's own history reads, which don't need it.
*/
user_id?: number | string;
}; };
/** /**
* The latest messages of a session, oldest first, with their attachments. * The latest messages of a session, oldest first, with their attachments,
* what each replies to, and (when `user_id` is given) its reactions.
*/ */
export default async function grabBotMessages({ export default async function grabBotMessages({
session_id, session_id,
limit = 100, limit = 100,
user_id,
}: Params): Promise<BotMessageWithAttachments[]> { }: Params): Promise<BotMessageWithAttachments[]> {
let sql = ``; let sql = ``;
@@ -31,15 +40,28 @@ export default async function grabBotMessages({
}); });
const messages = res.payload || []; const messages = res.payload || [];
const media = await grabMessagesMedia({ const message_ids = messages.map((message) => Number(message.id));
message_ids: messages.map((message) => Number(message.id)),
}); const [media, replies, reactions] = await Promise.all([
grabMessagesMedia({ message_ids }),
grabMessagesReplies({ message_ids }),
user_id
? grabMessagesReactions({ message_ids, user_id })
: Promise.resolve(new Map()),
]);
return messages.map((message) => { return messages.map((message) => {
const files = media.get(Number(message.id)); const id = Number(message.id);
const files = media.get(id);
const reply_to = replies.get(id);
return files return {
? { ...message, attachments: files.map((file) => mediaToAttachment({ media: file })) } ...message,
: message; ...(files
? { attachments: files.map((file) => mediaToAttachment({ media: file })) }
: {}),
...(reply_to ? { reply_to } : {}),
...(user_id ? { reactions: reactions.get(id) || [] } : {}),
};
}); });
} }
@@ -0,0 +1,21 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type { BUN_SQLITE_COBBLE_BOT_MESSAGES } from "@/db/types/db";
type Params = {
session_id: number | string;
};
/**
* The newest message the user wrote in a session, which a bot's reaction
* defaults to.
*/
export default async function grabLatestUserMessage({
session_id,
}: Params): Promise<BUN_SQLITE_COBBLE_BOT_MESSAGES | null> {
const res = await BunSQLite.sql<BUN_SQLITE_COBBLE_BOT_MESSAGES>({
sql: `SELECT * FROM bot_messages WHERE session_id = ? AND author = 'user' ORDER BY id DESC LIMIT 1`,
values: [session_id],
});
return res.payload?.[0] || null;
}
@@ -0,0 +1,70 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import {
BotMessageReactions,
type BotMessageReactionEmoji,
} from "@/src/dict/bot-dict";
import type { BotMessageReaction } from "@/src/types";
type Params = {
message_ids: number[];
user_id: number | string;
};
type ReactionRow = {
message_id: number;
emoji: BotMessageReactionEmoji;
user_id: number;
};
/**
* A message's reactions, grouped by emoji, in the picker's order, with
* whether the given user reacted and how many did.
*/
export default async function grabMessagesReactions({ message_ids, user_id }: Params) {
const by_message = new Map<number, BotMessageReaction[]>();
const ids = [...new Set(message_ids.filter(Boolean))];
if (!ids.length) return by_message;
const rows =
(
await BunSQLite.sql<ReactionRow>({
sql: `SELECT message_id, emoji, user_id FROM bot_message_reactions
WHERE message_id IN (${ids.map(() => "?").join(", ")})
ORDER BY id ASC`,
values: ids,
})
).payload || [];
const order = new Map<string, number>(
BotMessageReactions.map((reaction, index) => [reaction.value, index]),
);
for (const row of rows) {
const list = by_message.get(row.message_id) || [];
const found = list.find((reaction) => reaction.emoji == row.emoji);
if (found) {
found.count += 1;
found.mine = found.mine || String(row.user_id) == String(user_id);
} else {
list.push({
emoji: row.emoji,
count: 1,
mine: String(row.user_id) == String(user_id),
});
}
by_message.set(row.message_id, list);
}
for (const list of by_message.values()) {
list.sort(
(a, b) =>
(order.get(a.emoji) ?? BotMessageReactions.length) -
(order.get(b.emoji) ?? BotMessageReactions.length),
);
}
return by_message;
}
@@ -0,0 +1,35 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type { BotMessageReply } from "@/src/types";
type Params = {
message_ids: number[];
};
type ReplyRow = BotMessageReply & { message_id: number };
/**
* The messages quoted by these messages, by the replying message's id, so
* a reply can show what it answers without loading the whole history.
*/
export default async function grabMessagesReplies({ message_ids }: Params) {
const by_message = new Map<number, BotMessageReply>();
const ids = [...new Set(message_ids.filter(Boolean))];
if (!ids.length) return by_message;
const rows =
(
await BunSQLite.sql<ReplyRow>({
sql: `SELECT r.id, r.author, r.text, m.id AS message_id FROM bot_messages m
JOIN bot_messages r ON r.id = m.reply_to_message_id
WHERE m.id IN (${ids.map(() => "?").join(", ")})`,
values: ids,
})
).payload || [];
for (const { message_id, ...reply } of rows) {
by_message.set(Number(message_id), reply);
}
return by_message;
}
@@ -0,0 +1,23 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type { BUN_SQLITE_COBBLE_BOT_MESSAGES } from "@/db/types/db";
type Params = {
session_id: number | string;
message_id: number | string;
};
/**
* A message only if it's in the given session, so a reply can't quote
* another conversation's message.
*/
export default async function grabSessionMessage({
session_id,
message_id,
}: Params): Promise<BUN_SQLITE_COBBLE_BOT_MESSAGES | null> {
const res = await BunSQLite.sql<BUN_SQLITE_COBBLE_BOT_MESSAGES>({
sql: `SELECT * FROM bot_messages WHERE id = ? AND session_id = ? LIMIT 1`,
values: [message_id, session_id],
});
return res.payload?.[0] || null;
}
@@ -0,0 +1,32 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type { BUN_SQLITE_COBBLE_BOT_MESSAGES } from "@/db/types/db";
import AppError from "@/src/utils/app-error";
type Params = {
user_id: number | string;
message_id: number | string;
};
/**
* A message in one of the user's own sessions, whoever wrote it. Throws
* when it isn't theirs.
*/
export default async function grabUserBotMessage({
user_id,
message_id,
}: Params): Promise<BUN_SQLITE_COBBLE_BOT_MESSAGES> {
const message = (
await BunSQLite.sql<BUN_SQLITE_COBBLE_BOT_MESSAGES>({
sql: `SELECT m.* FROM bot_messages m
JOIN bot_sessions s ON s.id = m.session_id
WHERE m.id = ? AND s.user_id = ? LIMIT 1`,
values: [message_id, user_id],
})
).payload?.[0];
if (!message?.id) {
throw new AppError(`Message not found`);
}
return message;
}
@@ -15,6 +15,11 @@ type Params = {
session_id: number; session_id: number;
workspace_id?: number | null; workspace_id?: number | null;
text: string; text: string;
/**
* The message this one answers, quoted above it. Already checked to
* be in the same conversation.
*/
reply_to?: BUN_SQLITE_COBBLE_BOT_MESSAGES | null;
/** /**
* Files to attach, already checked to be the user's own * Files to attach, already checked to be the user's own
* (`grabAttachableMedia`). * (`grabAttachableMedia`).
@@ -33,6 +38,7 @@ export default async function insertUserMessage({
session_id, session_id,
workspace_id, workspace_id,
text, text,
reply_to,
media = [], media = [],
}: Params): Promise<BotMessageWithAttachments> { }: Params): Promise<BotMessageWithAttachments> {
const now = Date.now(); const now = Date.now();
@@ -50,6 +56,7 @@ export default async function insertUserMessage({
author: "user", author: "user",
text, text,
needs_you: 0, needs_you: 0,
reply_to_message_id: reply_to?.id || undefined,
}, },
], ],
}); });
@@ -80,8 +87,16 @@ export default async function insertUserMessage({
author: "user", author: "user",
text, text,
needs_you: 0, needs_you: 0,
reply_to_message_id: reply_to?.id || undefined,
created_at: now, created_at: now,
updated_at: now, updated_at: now,
attachments: media.map((file) => mediaToAttachment({ media: file })), attachments: media.map((file) => mediaToAttachment({ media: file })),
reply_to: reply_to
? {
id: Number(reply_to.id),
author: reply_to.author == "bot" ? "bot" : "user",
text: reply_to.text || "",
}
: null,
}; };
} }
@@ -0,0 +1,35 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import grabMessagesReactions from "./grab-messages-reactions";
type Params = {
message_id: number;
user_id: number | string;
emoji: string;
};
/**
* Adds a reaction, or takes it back when the user already gave it, and
* returns the message's fresh reactions.
*/
export default async function toggleMessageReaction({ message_id, user_id, emoji }: Params) {
const existing = (
await BunSQLite.sql<{ id: number }>({
sql: `SELECT id FROM bot_message_reactions WHERE message_id = ? AND user_id = ? AND emoji = ? LIMIT 1`,
values: [message_id, user_id, emoji],
})
).payload?.[0];
if (existing?.id) {
await BunSQLite.sql({
sql: `DELETE FROM bot_message_reactions WHERE id = ?`,
values: [existing.id],
});
} else {
await BunSQLite.sql({
sql: `INSERT OR IGNORE INTO bot_message_reactions (created_at, updated_at, message_id, user_id, emoji) VALUES (?, ?, ?, ?, ?)`,
values: [Date.now(), Date.now(), message_id, user_id, emoji],
});
}
return (await grabMessagesReactions({ message_ids: [message_id], user_id })).get(message_id) || [];
}
+1 -1
View File
@@ -74,7 +74,7 @@ export default async function grabBotChatTools({
}: Params): Promise<HarnessTool[]> { }: Params): Promise<HarnessTool[]> {
const bot_id = bot.id || 0; const bot_id = bot.id || 0;
const tools = [ const tools = [
...grabBotTools({ user_id, bot_id, workspace_id, computers }), ...grabBotTools({ user_id, bot_id, workspace_id, computers, session_id }),
startTaskTool({ startTaskTool({
user_id, user_id,
bot_id, bot_id,
+7
View File
@@ -7,6 +7,7 @@ import setWorkspaceComputerTool from "./set-workspace-computer-tool";
import updateScheduleTool from "./update-schedule-tool"; import updateScheduleTool from "./update-schedule-tool";
import updateInstructionsTool from "./update-instructions-tool"; import updateInstructionsTool from "./update-instructions-tool";
import updateMemoryTool from "./update-memory-tool"; import updateMemoryTool from "./update-memory-tool";
import reactToMessageTool from "./react-to-message-tool";
type Params = { type Params = {
user_id: number | string; user_id: number | string;
@@ -20,6 +21,10 @@ type Params = {
* The bot's computers where it runs, the current one first. * The bot's computers where it runs, the current one first.
*/ */
computers?: BUN_SQLITE_COBBLE_COMPUTERS[]; computers?: BUN_SQLITE_COBBLE_COMPUTERS[];
/**
* The session being answered, so the bot can react to its messages.
*/
session_id?: number | string;
}; };
/** /**
@@ -32,6 +37,7 @@ export default function grabBotTools({
bot_id, bot_id,
workspace_id, workspace_id,
computers = [], computers = [],
session_id,
}: Params): HarnessTool[] { }: Params): HarnessTool[] {
return [ return [
updateMemoryTool({ user_id, bot_id, workspace_id }), updateMemoryTool({ user_id, bot_id, workspace_id }),
@@ -40,6 +46,7 @@ export default function grabBotTools({
listSchedulesTool({ user_id, bot_id }), listSchedulesTool({ user_id, bot_id }),
updateScheduleTool({ user_id, bot_id }), updateScheduleTool({ user_id, bot_id }),
deleteScheduleTool({ user_id, bot_id }), deleteScheduleTool({ user_id, bot_id }),
...(session_id ? [reactToMessageTool({ bot_id, user_id, session_id })] : []),
...(workspace_id && computers.length > 1 ...(workspace_id && computers.length > 1
? [setWorkspaceComputerTool({ user_id, workspace_id, computers })] ? [setWorkspaceComputerTool({ user_id, workspace_id, computers })]
: []), : []),
@@ -0,0 +1,88 @@
import { z } from "zod";
import pushToSessionUser from "@/src/bots-queue/push-to-session-user";
import {
BotMessageReactions,
type BotMessageReactionEmoji,
} from "@/src/dict/bot-dict";
import addBotMessageReaction from "@/src/functions/backend/db/bots/add-bot-message-reaction";
import grabLatestUserMessage from "@/src/functions/backend/db/bots/grab-latest-user-message";
import grabSessionMessage from "@/src/functions/backend/db/bots/grab-session-message";
import type { HarnessTool } from "../../types";
type Params = {
bot_id: number | string;
/**
* The user whose conversation it is.
*/
user_id: number | string;
/**
* The session being answered: the reaction goes to a message in it,
* and the user's open conversation sees it live.
*/
session_id: number | string;
};
type Args = {
emoji: BotMessageReactionEmoji;
message_id?: number;
};
const ReactionEmojis = BotMessageReactions.map((reaction) => reaction.value) as [
BotMessageReactionEmoji,
...BotMessageReactionEmoji[],
];
const EmojiList = BotMessageReactions.map(
(reaction) => `${reaction.value} (${reaction.title.toLowerCase()})`,
).join(", ");
/**
* Lets a bot react to a message in the conversation it's answering. With
* no message id it reacts to the user's latest message, the one a reply is
* usually about. The reaction shows under the message and reaches the
* user's open conversation at once.
*/
export default function reactToMessageTool({ bot_id, user_id, session_id }: Params): HarnessTool<Args> {
return {
name: "react_to_message",
description: `React to a message in this conversation with an emoji (${EmojiList}). Use it when a reaction says it better than words, or when the user asks you to react. It doesn't replace answering. Without message_id it reacts to the user's latest message.`,
schema: z.object({
emoji: z.enum(ReactionEmojis).describe(`One of: ${EmojiList}`),
message_id: z
.number()
.int()
.positive()
.optional()
.describe(`The message to react to; defaults to the user's latest message`),
}),
run: async ({ args }) => {
const message = args.message_id
? await grabSessionMessage({
session_id,
message_id: args.message_id,
})
: await grabLatestUserMessage({ session_id });
if (!message?.id) {
throw new Error(`No message to react to in this conversation.`);
}
const reactions = await addBotMessageReaction({
message_id: Number(message.id),
bot_id,
user_id,
emoji: args.emoji,
});
await pushToSessionUser({
session_id,
data: {
event: "server:bot-reaction",
data: { message_id: Number(message.id), reactions },
},
});
return `Reacted with ${args.emoji}.`;
},
};
}
+18 -2
View File
@@ -1,5 +1,6 @@
import BunSQLite from "@moduletrace/bun-sqlite"; import BunSQLite from "@moduletrace/bun-sqlite";
import type { BUN_SQLITE_COBBLE_BOT_MESSAGES } from "@/db/types/db"; import type { BUN_SQLITE_COBBLE_BOT_MESSAGES } from "@/db/types/db";
import grabMessagesReplies from "@/src/functions/backend/db/bots/grab-messages-replies";
import { sendToUser } from "@/src/websocket/(functions)/send-to-user"; import { sendToUser } from "@/src/websocket/(functions)/send-to-user";
type Params = { type Params = {
@@ -9,7 +10,8 @@ type Params = {
/** /**
* Pushes `server:bot-message` for every message newer than `after_id` to * Pushes `server:bot-message` for every message newer than `after_id` to
* the sockets on this process of the user whose conversation it's in. Returns the newest id read. * the sockets on this process of the user whose conversation it's in,
* with the message a reply quotes. Returns the newest id read.
*/ */
export default async function relayNewBotMessages({ after_id, users }: Params) { export default async function relayNewBotMessages({ after_id, users }: Params) {
const rows = const rows =
@@ -21,9 +23,23 @@ export default async function relayNewBotMessages({ after_id, users }: Params) {
}) })
).payload || []; ).payload || [];
const replies = await grabMessagesReplies({
message_ids: rows
.filter((row) => row.reply_to_message_id && users.has(String(row.user_id)))
.map((row) => Number(row.id)),
});
for (const { user_id, ...message } of rows) { for (const { user_id, ...message } of rows) {
if (users.has(String(user_id))) { if (users.has(String(user_id))) {
sendToUser({ user_id, data: { event: "server:bot-message", data: message } }); const reply_to = replies.get(Number(message.id));
sendToUser({
user_id,
data: {
event: "server:bot-message",
data: reply_to ? { ...message, reply_to } : message,
},
});
} }
} }
@@ -0,0 +1,17 @@
import React from "react";
import type { BotMessageReactionEmoji } from "@/src/dict/bot-dict";
import type { BotMessageWithAttachments } from "@/src/types";
/**
* The conversation's reply and reaction handlers, shared by every
* message's actions.
*/
export type BotMessageActions = {
onReply: (params: { message: BotMessageWithAttachments }) => void;
onReact: (params: { message_id: number; emoji: BotMessageReactionEmoji }) => void;
};
export const BotMessageActionsContext = React.createContext<BotMessageActions>({
onReply: () => {},
onReact: () => {},
});
@@ -0,0 +1,18 @@
import fetchApi from "@moduletrace/twui/utils/fetch/fetchApi";
import type { APIResponseObject } from "@moduletrace/bunext/types";
import type { ReactToBotMessageRequest } from "@/src/schemas/bot-schema";
import type { BotMessageReactionData } from "@/src/types";
/**
* Adds or takes back an emoji reaction on a message, returning the
* message's fresh reactions.
*/
export default async function reactToBotMessage({ message_id, emoji }: ReactToBotMessageRequest) {
return await fetchApi<
ReactToBotMessageRequest,
APIResponseObject<BotMessageReactionData>
>(`/api/admin/bots/react-message`, {
method: "POST",
body: { message_id, emoji },
});
}
@@ -8,12 +8,13 @@ export default async function sendBotMessage({
workspace_id, workspace_id,
text, text,
media_ids, media_ids,
reply_to_message_id,
}: SendBotMessageRequest) { }: SendBotMessageRequest) {
return await fetchApi< return await fetchApi<
SendBotMessageRequest, SendBotMessageRequest,
APIResponseObject<BotMessageWithAttachments> APIResponseObject<BotMessageWithAttachments>
>(`/api/admin/bots/send-message`, { >(`/api/admin/bots/send-message`, {
method: "POST", method: "POST",
body: { bot_id, workspace_id, text, media_ids }, body: { bot_id, workspace_id, text, media_ids, reply_to_message_id },
}); });
} }
@@ -29,9 +29,8 @@ export default function BotMessageGroup({ bot, group }: Props) {
{group.messages.map((message, index) => ( {group.messages.map((message, index) => (
<BotMessageLine <BotMessageLine
key={message.id} key={message.id}
message_id={message.id || null} message={message}
text={message.text || ""} bot_name={bot.name || "the bot"}
needs_you={Boolean(message.needs_you)}
at={index == group.messages.length - 1 ? group.at : undefined} at={index == group.messages.length - 1 ? group.at : undefined}
/> />
))} ))}
@@ -1,17 +1,22 @@
import { useContext } from "react";
import Markdown from "react-markdown"; import Markdown from "react-markdown";
import remarkGfm from "remark-gfm"; import remarkGfm from "remark-gfm";
import Row from "@moduletrace/twui/layout/Row"; import Row from "@moduletrace/twui/layout/Row";
import Stack from "@moduletrace/twui/layout/Stack"; import Stack from "@moduletrace/twui/layout/Stack";
import type { BotMessageWithAttachments } from "@/src/types";
import linkComputerRefs from "@/src/utils/link-computer-refs"; import linkComputerRefs from "@/src/utils/link-computer-refs";
import { BotMessageActionsContext } from "../(data)/message-actions-context";
import { MessageMarkdownComponents } from "../(data)/message-markdown-components"; import { MessageMarkdownComponents } from "../(data)/message-markdown-components";
import GroupTimestamp from "./group-timestamp"; import GroupTimestamp from "./group-timestamp";
import MessageActions from "./message-actions";
import MessageReactions from "./message-reactions";
import MessageReplyQuote from "./message-reply-quote";
import NeedsYouChip from "./needs-you-chip"; import NeedsYouChip from "./needs-you-chip";
import SpeakMessageButton from "./speak-message-button"; import SpeakMessageButton from "./speak-message-button";
type Props = { type Props = {
message_id?: number | null; message: BotMessageWithAttachments;
text: string; bot_name: string;
needs_you?: boolean;
/** /**
* The group's time, on the last message only: shown beside its play * The group's time, on the last message only: shown beside its play
* button. * button.
@@ -22,32 +27,40 @@ type Props = {
/** /**
* One bot message, rendered from markdown (GFM; raw HTML is never * One bot message, rendered from markdown (GFM; raw HTML is never
* rendered). Computers it names (`computerRef`) show as buttons that * rendered). Computers it names (`computerRef`) show as buttons that
* switch the bot to them. A play button under it reads it aloud, * switch the bot to them. A quoted reply sits above it, its reactions
* disabled while the bot has no speech model, with the group's time * below, and a row under it replies, reacts, reads it aloud and shows
* before it on the last message. * why it needs you.
*/ */
export default function BotMessageLine({ message_id, text, needs_you, at }: Props) { export default function BotMessageLine({ message, bot_name, at }: Props) {
const can_speak = Boolean(message_id && text.trim()); const { onReact } = useContext(BotMessageActionsContext);
const can_speak = Boolean(message.id && message.text?.trim());
return ( return (
<Stack className="gap-1.5! items-start max-w-full"> <Stack className="w-full min-w-0 gap-1.5! items-start max-w-full">
{message.reply_to ? (
<MessageReplyQuote reply_to={message.reply_to} bot_name={bot_name} />
) : null}
<Stack className="w-full gap-2.5! items-start break-words"> <Stack className="w-full gap-2.5! items-start break-words">
<Markdown <Markdown
remarkPlugins={[remarkGfm]} remarkPlugins={[remarkGfm]}
components={MessageMarkdownComponents} components={MessageMarkdownComponents}
> >
{linkComputerRefs({ text })} {linkComputerRefs({ text: message.text || "" })}
</Markdown> </Markdown>
</Stack> </Stack>
{needs_you || can_speak || at ? ( {message.id ? (
<MessageReactions
message_id={message.id}
reactions={message.reactions}
onReact={onReact}
/>
) : null}
<Row className="gap-2! items-center"> <Row className="gap-2! items-center">
{needs_you ? <NeedsYouChip /> : null} {message.needs_you ? <NeedsYouChip /> : null}
{at ? <GroupTimestamp at={at} /> : null} {at ? <GroupTimestamp at={at} /> : null}
{can_speak ? ( {can_speak ? <SpeakMessageButton message_id={Number(message.id)} /> : null}
<SpeakMessageButton message_id={message_id!} /> <MessageActions message={message} />
) : null}
</Row> </Row>
) : null}
</Stack> </Stack>
); );
} }
@@ -0,0 +1,49 @@
import Button from "@moduletrace/twui/layout/Button";
import Row from "@moduletrace/twui/layout/Row";
import Stack from "@moduletrace/twui/layout/Stack";
import Span from "@moduletrace/twui/layout/Span";
import { Reply, X } from "lucide-react";
import type { BotMessageWithAttachments } from "@/src/types";
import messagePreview from "@/src/utils/message-preview";
type Props = {
message: BotMessageWithAttachments;
bot_name: string;
onCancel: () => void;
};
/**
* The message the next one will quote, above the composer, until it is
* sent or dropped.
*/
export default function ComposerReplyPreview({ message, bot_name, onCancel }: Props) {
const preview = messagePreview({ text: message.text || "" }) || "A file";
return (
<Row className="w-full max-w-container mx-auto gap-2! flex-nowrap items-center px-3 md:px-6 pt-4 pb-0">
<Row className="min-w-0 grow gap-2! flex-nowrap items-center overflow-hidden rounded-default-lg border border-solid border-hairline bg-surface px-3 py-1.5">
<Reply size={14} aria-hidden className="shrink-0 text-ink-secondary" />
<Stack className="min-w-0 grow gap-0.5!">
<Span className="block w-full truncate text-[11px] font-bold leading-4 text-ink">
Replying to {message.author == "user" ? "yourself" : bot_name}
</Span>
<Span className="block w-full truncate text-xs leading-4 text-ink-secondary">
{preview}
</Span>
</Stack>
</Row>
<Button
title="Cancel reply"
aria-label="Cancel reply"
type="button"
variant="ghost"
color="gray"
size="small"
className="shrink-0 p-1!"
onClick={onCancel}
>
<X size={14} aria-hidden />
</Button>
</Row>
);
}
@@ -1,4 +1,4 @@
import { useRef, useState, type KeyboardEvent } from "react"; import { useEffect, useRef, useState, type KeyboardEvent } 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 Textarea from "@moduletrace/twui/form/Textarea"; import Textarea from "@moduletrace/twui/form/Textarea";
@@ -18,6 +18,7 @@ import useVoiceRecorder from "../(hooks)/use-voice-recorder";
import useOnlineStatus from "@/src/hooks/use-online-status"; import useOnlineStatus from "@/src/hooks/use-online-status";
import ComposerAttachButton from "./composer-attach-button"; import ComposerAttachButton from "./composer-attach-button";
import ComposerAttachments from "./composer-attachments"; import ComposerAttachments from "./composer-attachments";
import ComposerReplyPreview from "./composer-reply-preview";
import ComposerSendButton from "./composer-send-button"; import ComposerSendButton from "./composer-send-button";
import ComposerTools from "./composer-tools"; import ComposerTools from "./composer-tools";
import ComposerVoiceButton from "./composer-voice-button"; import ComposerVoiceButton from "./composer-voice-button";
@@ -36,6 +37,11 @@ type Props = {
* The dictation mic is disabled while transcription has no model. * The dictation mic is disabled while transcription has no model.
*/ */
voice?: BotVoicePayload | null; voice?: BotVoicePayload | null;
/**
* The message the next one quotes, if the user picked one.
*/
reply_to?: BotMessageWithAttachments | null;
onCancelReply?: () => void;
onSent: (params: { message: BotMessageWithAttachments }) => void; onSent: (params: { message: BotMessageWithAttachments }) => void;
}; };
@@ -48,7 +54,15 @@ type Props = {
* are listed under the input. The mic dictates: what's said is written * are listed under the input. The mic dictates: what's said is written
* into the input at the cursor, to edit before sending. * into the input at the cursor, to edit before sending.
*/ */
export default function Composer({ bot, workspace_id, extensions, voice, onSent }: Props) { export default function Composer({
bot,
workspace_id,
extensions,
voice,
reply_to,
onCancelReply,
onSent,
}: Props) {
const { text, setText } = useComposerDraft({ bot_id: bot.id, workspace_id }); const { text, setText } = useComposerDraft({ bot_id: bot.id, workspace_id });
const [sending, setSending] = useState(false); const [sending, setSending] = useState(false);
const [error, setError] = useState<string>(); const [error, setError] = useState<string>();
@@ -58,6 +72,13 @@ export default function Composer({ bot, workspace_id, extensions, voice, onSent
useAutoGrowTextarea({ ref: input_ref, value: text }); useAutoGrowTextarea({ ref: input_ref, value: text });
/**
* Picking a message to reply to puts the cursor back in the input.
*/
useEffect(() => {
if (reply_to) input_ref.current?.focus();
}, [reply_to]);
const recorder = useVoiceRecorder({ const recorder = useVoiceRecorder({
onRecorded: async ({ audio }) => { onRecorded: async ({ audio }) => {
if (!bot.id) return; if (!bot.id) return;
@@ -115,6 +136,7 @@ export default function Composer({ bot, workspace_id, extensions, voice, onSent
workspace_id, workspace_id,
text, text,
media_ids: files.media_ids, media_ids: files.media_ids,
reply_to_message_id: reply_to?.id || null,
}); });
setSending(false); setSending(false);
@@ -156,7 +178,14 @@ export default function Composer({ bot, workspace_id, extensions, voice, onSent
onRemove={files.remove} onRemove={files.remove}
/> />
<Stack className="shrink-0 gap-0! items-stretch border-0 border-t border-solid border-hairline"> <Stack className="shrink-0 gap-0! items-stretch border-0 border-t border-solid border-hairline">
<Row className="w-full max-w-container mx-auto min-h-[88px] py-7 gap-3! md:gap-4! flex-nowrap items-end px-3 md:px-6 cursor-text" {reply_to && onCancelReply ? (
<ComposerReplyPreview
message={reply_to}
bot_name={bot.name || "the bot"}
onCancel={onCancelReply}
/>
) : null}
<Row className={`w-full max-w-container mx-auto min-h-[88px] ${reply_to ? "pt-3 pb-7" : "py-7"} gap-3! md:gap-4! flex-nowrap items-end px-3 md:px-6 cursor-text`}
onMouseDown={(e) => { onMouseDown={(e) => {
const target = e.target as HTMLElement; const target = e.target as HTMLElement;
@@ -0,0 +1,29 @@
import Button from "@moduletrace/twui/layout/Button";
import type { ReactNode } from "react";
type Props = {
title: string;
aria_label: string;
onClick: () => void;
children: ReactNode;
};
/**
* A small ghost icon button under a message, for its reply and reaction
* actions. Quiet until hovered, and readable in both themes.
*/
export default function MessageActionButton({ title, aria_label, onClick, children }: Props) {
return (
<Button
title={title}
aria-label={aria_label}
type="button"
variant="ghost"
color="text"
onClick={onClick}
className="shrink-0 p-1! -mx-1 text-ink-secondary dark:text-ink-secondary hover:text-ink rounded-default-xs focus-visible:ring-2 focus-visible:ring-ink"
>
{children}
</Button>
);
}
@@ -0,0 +1,33 @@
import { useContext } from "react";
import Row from "@moduletrace/twui/layout/Row";
import { Reply } from "lucide-react";
import type { BotMessageWithAttachments } from "@/src/types";
import { BotMessageActionsContext } from "../(data)/message-actions-context";
import MessageActionButton from "./message-action-button";
import MessageReactionPicker from "./message-reaction-picker";
type Props = {
message: BotMessageWithAttachments;
};
/**
* A message's reply and reaction actions, shown under it.
*/
export default function MessageActions({ message }: Props) {
const { onReply, onReact } = useContext(BotMessageActionsContext);
if (!message.id) return null;
return (
<Row className="gap-1.5! items-center">
<MessageActionButton
title="Reply"
aria_label="Reply to this message"
onClick={() => onReply({ message })}
>
<Reply size={14} aria-hidden />
</MessageActionButton>
<MessageReactionPicker message_id={message.id} onReact={onReact} />
</Row>
);
}
@@ -10,7 +10,7 @@ type Props = {
export default function MessageGroupBlock({ bot, group }: Props) { export default function MessageGroupBlock({ bot, group }: Props) {
if (group.author === "user") { if (group.author === "user") {
return <UserMessageGroup group={group} />; return <UserMessageGroup bot={bot} group={group} />;
} }
return <BotMessageGroup bot={bot} group={group} />; return <BotMessageGroup bot={bot} group={group} />;
@@ -0,0 +1,33 @@
import Button from "@moduletrace/twui/layout/Button";
import Span from "@moduletrace/twui/layout/Span";
import type { BotMessageReaction } from "@/src/types";
type Props = BotMessageReaction & {
onToggle: () => void;
};
/**
* One emoji reaction under a message: its count, highlighted when the
* user gave it. Clicking takes it back or adds it.
*/
export default function MessageReactionChip({ emoji, count, mine, onToggle }: Props) {
return (
<Button
title={mine ? "Remove your reaction" : "Add this reaction"}
aria-label={mine ? "Remove your reaction" : "Add this reaction"}
aria-pressed={mine}
type="button"
variant="ghost"
color="text"
onClick={onToggle}
className={`shrink-0 gap-1! px-2! py-0.5! rounded-default-lg border border-solid text-xs leading-4 ${
mine
? "border-hairline-strong bg-sunken text-ink"
: "border-hairline bg-surface text-ink-secondary hover:text-ink"
} focus-visible:ring-2 focus-visible:ring-ink`}
>
<Span className="text-sm leading-4">{emoji}</Span>
{count > 1 ? <Span className="tabular-nums">{count}</Span> : null}
</Button>
);
}
@@ -0,0 +1,27 @@
import Button from "@moduletrace/twui/layout/Button";
import Span from "@moduletrace/twui/layout/Span";
type Props = {
title: string;
value: string;
onPick: () => void;
};
/**
* One emoji in the reaction picker.
*/
export default function MessageReactionOption({ title, value, onPick }: Props) {
return (
<Button
title={title}
aria-label={`React with ${title}`}
type="button"
variant="ghost"
color="text"
onClick={onPick}
className="shrink-0 px-2! py-1! rounded-default-xs hover:bg-sunken focus-visible:ring-2 focus-visible:ring-ink"
>
<Span className="text-base leading-5">{value}</Span>
</Button>
);
}
@@ -0,0 +1,52 @@
import { useState } from "react";
import Dropdown from "@moduletrace/twui/elements/Dropdown";
import Row from "@moduletrace/twui/layout/Row";
import { SmilePlus } from "lucide-react";
import { BotMessageReactions, type BotMessageReactionEmoji } from "@/src/dict/bot-dict";
import MessageActionButton from "./message-action-button";
import MessageReactionOption from "./message-reaction-option";
type Props = {
message_id: number;
onReact: (params: { message_id: number; emoji: BotMessageReactionEmoji }) => void;
};
/**
* Opens the fixed set of reactions and adds one to the message. The
* picker closes as soon as one is picked.
*/
export default function MessageReactionPicker({ message_id, onReact }: Props) {
const [open, setOpen] = useState(false);
return (
<Dropdown
position="top-left"
externalOpen={open}
externalSetOpen={setOpen}
contentWrapperProps={{ className: "pb-1" }}
target={
<MessageActionButton
title="Add a reaction"
aria_label="Add a reaction"
onClick={() => {}}
>
<SmilePlus size={14} aria-hidden />
</MessageActionButton>
}
>
<Row className="gap-0! flex-nowrap items-center rounded-default-lg border border-solid border-hairline bg-surface p-1 shadow-popover">
{BotMessageReactions.map((reaction) => (
<MessageReactionOption
key={reaction.value}
title={reaction.title}
value={reaction.value}
onPick={() => {
setOpen(false);
onReact({ message_id, emoji: reaction.value });
}}
/>
))}
</Row>
</Dropdown>
);
}
@@ -0,0 +1,31 @@
import Row from "@moduletrace/twui/layout/Row";
import type { BotMessageReactionEmoji } from "@/src/dict/bot-dict";
import type { BotMessageReaction } from "@/src/types";
import MessageReactionChip from "./message-reaction-chip";
type Props = {
message_id: number;
reactions?: BotMessageReaction[];
onReact: (params: { message_id: number; emoji: BotMessageReactionEmoji }) => void;
};
/**
* The reactions a message has, one chip per emoji, each toggleable.
*/
export default function MessageReactions({ message_id, reactions, onReact }: Props) {
if (!reactions?.length) return null;
return (
<Row className="gap-1.5! flex-wrap items-center">
{reactions.map((reaction) => (
<MessageReactionChip
key={reaction.emoji}
emoji={reaction.emoji}
count={reaction.count}
mine={reaction.mine}
onToggle={() => onReact({ message_id, emoji: reaction.emoji })}
/>
))}
</Row>
);
}
@@ -0,0 +1,41 @@
import Row from "@moduletrace/twui/layout/Row";
import Stack from "@moduletrace/twui/layout/Stack";
import Span from "@moduletrace/twui/layout/Span";
import { Reply } from "lucide-react";
import type { BotMessageReply } from "@/src/types";
import messagePreview from "@/src/utils/message-preview";
type Props = {
reply_to: BotMessageReply;
bot_name: string;
/**
* Right-align it, for a user message.
*/
align_end?: boolean;
};
/**
* The message a reply answers: who wrote it and a one-line preview. A
* file-only message says so instead of showing an empty line.
*/
export default function MessageReplyQuote({ reply_to, bot_name, align_end }: Props) {
const preview = messagePreview({ text: reply_to.text }) || "A file";
return (
<Stack
className={`w-full max-w-[80%] min-w-0 gap-0.5! overflow-hidden border-0 border-l-2 border-solid border-hairline-strong pl-2.5 ${
align_end ? "items-end text-right" : "items-start"
}`}
>
<Row className="max-w-full min-w-0 gap-1! items-center">
<Reply size={11} aria-hidden className="shrink-0 text-ink-secondary" />
<Span className="min-w-0 truncate text-[11px] font-bold leading-4 text-ink">
{reply_to.author == "user" ? "You" : bot_name}
</Span>
</Row>
<Span className="block w-full max-w-full break-words text-xs leading-4 text-ink-secondary line-clamp-2">
{preview}
</Span>
</Stack>
);
}
@@ -1,17 +1,23 @@
import Stack from "@moduletrace/twui/layout/Stack"; import Stack from "@moduletrace/twui/layout/Stack";
import type { BotWithLastMessage } from "@/src/types";
import type { MessageGroup } from "../(utils)/group-messages"; import type { MessageGroup } from "../(utils)/group-messages";
import UserMessage from "./user-message"; import UserMessage from "./user-message";
import GroupTimestamp from "./group-timestamp"; import GroupTimestamp from "./group-timestamp";
type Props = {
bot: BotWithLastMessage;
group: MessageGroup;
};
/** /**
* Right-aligned ink bubbles (each message's files above it), one * Right-aligned ink bubbles (each message's files above it), one
* timestamp under the group. * timestamp under the group.
*/ */
export default function UserMessageGroup({ group }: { group: MessageGroup }) { export default function UserMessageGroup({ bot, group }: Props) {
return ( return (
<Stack className="w-full gap-1! items-end"> <Stack className="w-full gap-1! items-end">
{group.messages.map((message) => ( {group.messages.map((message) => (
<UserMessage key={message.id} {...message} /> <UserMessage key={message.id} message={message} bot_name={bot.name || "the bot"} />
))} ))}
<GroupTimestamp at={group.at} /> <GroupTimestamp at={group.at} />
</Stack> </Stack>
@@ -1,16 +1,44 @@
import { useContext } from "react";
import Stack from "@moduletrace/twui/layout/Stack";
import type { BotMessageWithAttachments } from "@/src/types"; import type { BotMessageWithAttachments } from "@/src/types";
import { BotMessageActionsContext } from "../(data)/message-actions-context";
import MessageActions from "./message-actions";
import MessageAttachments from "./message-attachments"; import MessageAttachments from "./message-attachments";
import MessageReactions from "./message-reactions";
import MessageReplyQuote from "./message-reply-quote";
import UserBubble from "./user-bubble"; import UserBubble from "./user-bubble";
type Props = {
message: BotMessageWithAttachments;
bot_name: string;
};
/** /**
* One user message: its files, then its text (a file-only message has no * One user message: the quoted message it answers, its files, then its
* bubble). * text (a file-only message has no bubble), its reactions and actions.
*/ */
export default function UserMessage({ text, attachments }: BotMessageWithAttachments) { export default function UserMessage({ message, bot_name }: Props) {
const { onReact } = useContext(BotMessageActionsContext);
return ( return (
<> <Stack className="w-full min-w-0 max-w-full items-end gap-1.5!">
<MessageAttachments attachments={attachments} /> {message.reply_to ? (
{text ? <UserBubble text={text} /> : null} <MessageReplyQuote
</> reply_to={message.reply_to}
bot_name={bot_name}
align_end
/>
) : null}
<MessageAttachments attachments={message.attachments} />
{message.text ? <UserBubble text={message.text} /> : null}
{message.id ? (
<MessageReactions
message_id={message.id}
reactions={message.reactions}
onReact={onReact}
/>
) : null}
<MessageActions message={message} />
</Stack>
); );
} }
@@ -1,12 +1,18 @@
import { useContext, useState } from "react"; import { useContext, useEffect, useState } from "react";
import Stack from "@moduletrace/twui/layout/Stack"; import Stack from "@moduletrace/twui/layout/Stack";
import type { BotMessageWithAttachments } from "@/src/types"; import type { BotMessageReactionEmoji } from "@/src/dict/bot-dict";
import type {
BotMessageReactionData,
BotMessageWithAttachments,
} from "@/src/types";
import { AppContext } from "@/src/pages/__root"; import { AppContext } from "@/src/pages/__root";
import fetchApi from "@moduletrace/twui/utils/fetch/fetchApi"; import fetchApi from "@moduletrace/twui/utils/fetch/fetchApi";
import useLiveBots from "@/src/hooks/use-live-bots"; import useLiveBots from "@/src/hooks/use-live-bots";
import useServerEvent from "@/src/hooks/use-server-event"; import useServerEvent from "@/src/hooks/use-server-event";
import { BotComputersContext } from "../(data)/bot-computers-context"; import { BotComputersContext } from "../(data)/bot-computers-context";
import { BotMessageActionsContext } from "../(data)/message-actions-context";
import { SpeakMessageContext } from "../(data)/speak-message-context"; import { SpeakMessageContext } from "../(data)/speak-message-context";
import reactToBotMessage from "../(functions)/react-to-bot-message";
import useBotComputers from "../(hooks)/use-bot-computers"; import useBotComputers from "../(hooks)/use-bot-computers";
import useTaskProgress from "../(hooks)/use-task-progress"; import useTaskProgress from "../(hooks)/use-task-progress";
import useReplyProgress from "../(hooks)/use-reply-progress"; import useReplyProgress from "../(hooks)/use-reply-progress";
@@ -37,6 +43,14 @@ export default function Conversation() {
); );
const [session_id, setSessionId] = useState<number | null>(pageProps?.bot_session?.id || null); const [session_id, setSessionId] = useState<number | null>(pageProps?.bot_session?.id || null);
const [has_other_unread, setHasOtherUnread] = useState(false); const [has_other_unread, setHasOtherUnread] = useState(false);
const [reply_to, setReplyTo] = useState<BotMessageWithAttachments | null>(null);
/**
* A quote picked in one session doesn't follow the thread to another.
*/
useEffect(() => {
setReplyTo(null);
}, [session_id]);
const bots = useLiveBots({ const bots = useLiveBots({
bots: pageProps?.bots || [], bots: pageProps?.bots || [],
@@ -113,11 +127,43 @@ export default function Conversation() {
}, },
}); });
/**
* A reaction toggled here or in another tab: the pushed list replaces
* the message's own.
*/
const applyReactions = ({ message_id, reactions }: BotMessageReactionData) => {
setMessages((prev) =>
prev.map((message) =>
Number(message.id) == message_id ? { ...message, reactions } : message,
),
);
};
useServerEvent({ event: "server:bot-reaction", onData: applyReactions });
const reactToMessage = async ({
message_id,
emoji,
}: {
message_id: number;
emoji: BotMessageReactionEmoji;
}) => {
const res = await reactToBotMessage({ message_id, emoji });
if (res.success && res.singleRes) applyReactions(res.singleRes);
};
if (!bot?.id || !live_bot) return null; if (!bot?.id || !live_bot) return null;
return ( return (
<BotComputersContext.Provider value={bot_computers}> <BotComputersContext.Provider value={bot_computers}>
<SpeakMessageContext.Provider value={speak}> <SpeakMessageContext.Provider value={speak}>
<BotMessageActionsContext.Provider
value={{
onReply: ({ message }) => setReplyTo(message),
onReact: reactToMessage,
}}
>
<Stack className="grow min-w-0 h-full gap-0! items-stretch"> <Stack className="grow min-w-0 h-full gap-0! items-stretch">
<ConversationHeader <ConversationHeader
bot={live_bot} bot={live_bot}
@@ -137,7 +183,11 @@ export default function Conversation() {
workspace_id={workspace_id} workspace_id={workspace_id}
extensions={pageProps?.bot_extensions} extensions={pageProps?.bot_extensions}
voice={pageProps?.bot_voice} voice={pageProps?.bot_voice}
reply_to={reply_to}
onCancelReply={() => setReplyTo(null)}
onSent={({ message }) => { onSent={({ message }) => {
setReplyTo(null);
if (startsNewSession(message) && message.session_id != session_id) { if (startsNewSession(message) && message.session_id != session_id) {
setSessionId(Number(message.session_id)); setSessionId(Number(message.session_id));
setMessages([message]); setMessages([message]);
@@ -152,6 +202,7 @@ export default function Conversation() {
}} }}
/> />
</Stack> </Stack>
</BotMessageActionsContext.Provider>
<ComputerViewerModal bot={live_bot} /> <ComputerViewerModal bot={live_bot} />
</SpeakMessageContext.Provider> </SpeakMessageContext.Provider>
</BotComputersContext.Provider> </BotComputersContext.Provider>
+60
View File
@@ -0,0 +1,60 @@
import userAuth from "@/src/functions/backend/auth/user-auth";
import grabUserBotMessage from "@/src/functions/backend/db/bots/grab-user-bot-message";
import toggleMessageReaction from "@/src/functions/backend/db/bots/toggle-message-reaction";
import pushToSessionUser from "@/src/bots-queue/push-to-session-user";
import { ReactToBotMessageRequestSchema } from "@/src/schemas/bot-schema";
import type { APIResponseObject, BunextAPIRouteHandler } from "@moduletrace/bunext/types";
import AppError from "@/src/utils/app-error";
import parseWithSchema from "@/src/utils/parse-with-schema";
import isReadOnlyUser from "@/src/utils/rbac/is-read-only-user";
import safeErrorMessage from "@/src/utils/safe-error-message";
/**
* Adds or takes back one of the fixed emoji reactions on a message in the
* session user's own conversation, and pushes the message's fresh
* reactions to their other open tabs.
*/
export const handler: BunextAPIRouteHandler<APIResponseObject> = async ({ req, body }) => {
try {
if (req.method != "POST") {
throw new AppError(`Method not allowed`);
}
const { user, user_types } = await userAuth({ req });
if (!user?.logged_in_status || !user.id) {
return { success: false, msg: `Unauthorized`, logoutUser: true };
}
if (isReadOnlyUser({ user_types: user_types || [] })) {
throw new AppError(`This user can only read records.`);
}
const { message_id, emoji } = parseWithSchema({
schema: ReactToBotMessageRequestSchema,
data: body,
});
const message = await grabUserBotMessage({ user_id: user.id, message_id });
const reactions = await toggleMessageReaction({
message_id: Number(message.id),
user_id: user.id,
emoji,
});
await pushToSessionUser({
session_id: message.session_id,
data: {
event: "server:bot-reaction",
data: { message_id: Number(message.id), reactions },
},
});
return {
success: true,
singleRes: { message_id: Number(message.id), reactions },
};
} catch (error: unknown) {
return { success: false, msg: safeErrorMessage({ error }) };
}
};
+23 -1
View File
@@ -2,6 +2,7 @@ import userAuth from "@/src/functions/backend/auth/user-auth";
import grabUserConversation from "@/src/functions/backend/db/bot-sessions/grab-user-conversation"; import grabUserConversation from "@/src/functions/backend/db/bot-sessions/grab-user-conversation";
import ensureBotSession from "@/src/functions/backend/db/bot-sessions/ensure-bot-session"; import ensureBotSession from "@/src/functions/backend/db/bot-sessions/ensure-bot-session";
import insertUserMessage from "@/src/functions/backend/db/bots/insert-user-message"; import insertUserMessage from "@/src/functions/backend/db/bots/insert-user-message";
import grabUserBotMessage from "@/src/functions/backend/db/bots/grab-user-bot-message";
import grabAttachableMedia from "@/src/functions/backend/db/bots/grab-attachable-media"; import grabAttachableMedia from "@/src/functions/backend/db/bots/grab-attachable-media";
import enqueueBotReply from "@/src/functions/backend/db/bots-queue/enqueue-bot-reply"; import enqueueBotReply from "@/src/functions/backend/db/bots-queue/enqueue-bot-reply";
import pushBotNotice from "@/src/bots-queue/push-bot-notice"; import pushBotNotice from "@/src/bots-queue/push-bot-notice";
@@ -47,7 +48,7 @@ export const handler: BunextAPIRouteHandler<APIResponseObject> = async ({
throw new AppError(`This user can only read records.`); throw new AppError(`This user can only read records.`);
} }
const { bot_id, workspace_id, text, media_ids } = parseWithSchema({ const { bot_id, workspace_id, text, media_ids, reply_to_message_id } = parseWithSchema({
schema: SendBotMessageRequestSchema, schema: SendBotMessageRequestSchema,
data: body, data: body,
}); });
@@ -63,11 +64,32 @@ export const handler: BunextAPIRouteHandler<APIResponseObject> = async ({
rollover: true, rollover: true,
}); });
/**
* The quoted message only has to be in this conversation, not in
* the session the message lands in: sending can roll over to a new
* one, or the thread may have moved on.
*/
const reply_to = reply_to_message_id
? await grabUserBotMessage({ user_id: user.id, message_id: reply_to_message_id }).catch(
() => null,
)
: null;
if (
reply_to_message_id &&
(!reply_to ||
reply_to.bot_id != bot.id ||
(reply_to.workspace_id || null) != (conversation.workspace_id || null))
) {
throw new AppError(`That message is no longer in this conversation.`);
}
const message = await insertUserMessage({ const message = await insertUserMessage({
bot_id: bot.id, bot_id: bot.id,
session_id: Number(session.id), session_id: Number(session.id),
workspace_id: conversation.workspace_id, workspace_id: conversation.workspace_id,
text, text,
reply_to,
media, media,
}); });
@@ -43,6 +43,7 @@ export const handler: BunextAPIRouteHandler<
payload: await grabBotMessages({ payload: await grabBotMessages({
session_id: session.id, session_id: session.id,
limit: AppData.BotReplyCatchUpMessages, limit: AppData.BotReplyCatchUpMessages,
user_id: user.id,
}), }),
}; };
} catch (error: unknown) { } catch (error: unknown) {
+25 -1
View File
@@ -1,6 +1,11 @@
import { z } from "zod"; import { z } from "zod";
import { AppData } from "@/src/data/app-data"; import { AppData } from "@/src/data/app-data";
import { BotHues, type BotHue } from "@/src/dict/bot-dict"; import {
BotHues,
BotMessageReactions,
type BotHue,
type BotMessageReactionEmoji,
} from "@/src/dict/bot-dict";
/** /**
* The workspace a conversation is in; left out (or null) for a bot's own * The workspace a conversation is in; left out (or null) for a bot's own
@@ -16,10 +21,29 @@ export const SendBotMessageRequestSchema = z.object({
.array(z.coerce.number().int().positive()) .array(z.coerce.number().int().positive())
.max(AppData.BotAttachmentMaxFiles, `Attach up to ${AppData.BotAttachmentMaxFiles} files`) .max(AppData.BotAttachmentMaxFiles, `Attach up to ${AppData.BotAttachmentMaxFiles} files`)
.default([]), .default([]),
/**
* The message this one replies to, quoted above it.
*/
reply_to_message_id: z.coerce.number().int().positive().nullish(),
}).refine((body) => body.text || body.media_ids.length, "Write a message or attach a file first"); }).refine((body) => body.text || body.media_ids.length, "Write a message or attach a file first");
export type SendBotMessageRequest = z.infer<typeof SendBotMessageRequestSchema>; export type SendBotMessageRequest = z.infer<typeof SendBotMessageRequestSchema>;
/**
* Toggling one of the fixed reactions on a message.
*/
export const ReactToBotMessageRequestSchema = z.object({
message_id: z.coerce.number().int().positive("Pick a message"),
emoji: z.enum(
BotMessageReactions.map((reaction) => reaction.value) as [
BotMessageReactionEmoji,
...BotMessageReactionEmoji[],
],
),
});
export type ReactToBotMessageRequest = z.infer<typeof ReactToBotMessageRequestSchema>;
export const BotIdRequestSchema = z.object({ export const BotIdRequestSchema = z.object({
bot_id: z.coerce.number().int().positive("Pick a bot"), bot_id: z.coerce.number().int().positive("Pick a bot"),
}); });
+36 -1
View File
@@ -26,7 +26,7 @@ import type { HarnessKind } from "../dict/harness-dict";
import type { HarnessProviderKind } from "../dict/harness-provider-dict"; import type { HarnessProviderKind } from "../dict/harness-provider-dict";
import type { EnvParadigm, KnownEnv } from "../dict/env-dict"; import type { EnvParadigm, KnownEnv } from "../dict/env-dict";
import type { ContainerStatus } from "../dict/computer-dict"; import type { ContainerStatus } from "../dict/computer-dict";
import type { BotFace } from "../dict/bot-dict"; import type { BotFace, BotMessageReactionEmoji } from "../dict/bot-dict";
import type { McpServerStatus, McpTransport } from "../dict/mcp-dict"; import type { McpServerStatus, McpTransport } from "../dict/mcp-dict";
import type { import type {
CustomToolHttpMethod, CustomToolHttpMethod,
@@ -587,8 +587,39 @@ export type BotMessageAttachment = {
thumbnail_url?: string; thumbnail_url?: string;
}; };
/**
* An emoji reaction on a message, grouped by emoji.
*/
export type BotMessageReaction = {
emoji: BotMessageReactionEmoji;
count: number;
/**
* The session user reacted with it.
*/
mine: boolean;
};
/**
* The message a reply quotes, as the thread shows it.
*/
export type BotMessageReply = {
id: number;
author: "bot" | "user";
text: string;
};
export type BotMessageWithAttachments = BUN_SQLITE_COBBLE_BOT_MESSAGES & { export type BotMessageWithAttachments = BUN_SQLITE_COBBLE_BOT_MESSAGES & {
attachments?: BotMessageAttachment[]; attachments?: BotMessageAttachment[];
reactions?: BotMessageReaction[];
reply_to?: BotMessageReply | null;
};
/**
* `server:bot-reaction` data: a message's reactions changed.
*/
export type BotMessageReactionData = {
message_id: number;
reactions: BotMessageReaction[];
}; };
export type BotHarnessOverviewPayload = { export type BotHarnessOverviewPayload = {
@@ -877,6 +908,10 @@ export const WebSocketEvents = [
* `WorkspaceRemovedData`) * `WorkspaceRemovedData`)
*/ */
"server:workspace-removed", "server:workspace-removed",
/**
* A message's emoji reactions changed (`data`: `BotMessageReactionData`)
*/
"server:bot-reaction",
/** /**
* A running job took a step (`data`: `BotJobUpdateData`) * A running job took a step (`data`: `BotJobUpdateData`)
*/ */