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:
@@ -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", () => {
|
||||
const attachments = [{ id: 9, name: "shot.png", media_type: "image" as const, url: "/media/private/9" }];
|
||||
|
||||
|
||||
@@ -2,7 +2,10 @@ import type { HarnessContentPart, HarnessMessage } from "@/src/harness/types";
|
||||
import type { BotMessageWithAttachments } from "@/src/types";
|
||||
|
||||
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
|
||||
* messages are only named.
|
||||
@@ -61,7 +64,7 @@ function messageParts({
|
||||
role: Turn["role"];
|
||||
parts?: Params["parts"];
|
||||
}): HarnessContentPart[] {
|
||||
const text = message.text?.trim();
|
||||
const text = replyContext({ message, text: message.text?.trim() || "" });
|
||||
const content: HarnessContentPart[] = text ? [{ type: "text", text }] : [];
|
||||
|
||||
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.
|
||||
*/
|
||||
|
||||
@@ -87,7 +87,7 @@ export default async function runBotTask({
|
||||
];
|
||||
|
||||
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"
|
||||
? [
|
||||
createWorkspaceTool({ user_id, workspaces: coordinator_workspaces }),
|
||||
|
||||
@@ -95,6 +95,39 @@ export const BotMessageAuthors = [
|
||||
|
||||
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 = [
|
||||
{
|
||||
title: "Active",
|
||||
|
||||
@@ -67,7 +67,7 @@ export default async function grabConversationProps({
|
||||
* shows its pick.
|
||||
*/
|
||||
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
|
||||
? grabHarnessOverview().then(({ harnesses }) => ({ harnesses }))
|
||||
: 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 BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import grabMessagesMedia from "./grab-messages-media";
|
||||
import grabMessagesReactions from "./grab-messages-reactions";
|
||||
import grabMessagesReplies from "./grab-messages-replies";
|
||||
import mediaToAttachment from "./media-to-attachment";
|
||||
|
||||
type Params = {
|
||||
session_id: number | string;
|
||||
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({
|
||||
session_id,
|
||||
limit = 100,
|
||||
user_id,
|
||||
}: Params): Promise<BotMessageWithAttachments[]> {
|
||||
let sql = ``;
|
||||
|
||||
@@ -31,15 +40,28 @@ export default async function grabBotMessages({
|
||||
});
|
||||
|
||||
const messages = res.payload || [];
|
||||
const media = await grabMessagesMedia({
|
||||
message_ids: messages.map((message) => Number(message.id)),
|
||||
});
|
||||
const 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) => {
|
||||
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
|
||||
? { ...message, attachments: files.map((file) => mediaToAttachment({ media: file })) }
|
||||
: message;
|
||||
return {
|
||||
...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;
|
||||
workspace_id?: number | null;
|
||||
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
|
||||
* (`grabAttachableMedia`).
|
||||
@@ -33,6 +38,7 @@ export default async function insertUserMessage({
|
||||
session_id,
|
||||
workspace_id,
|
||||
text,
|
||||
reply_to,
|
||||
media = [],
|
||||
}: Params): Promise<BotMessageWithAttachments> {
|
||||
const now = Date.now();
|
||||
@@ -50,6 +56,7 @@ export default async function insertUserMessage({
|
||||
author: "user",
|
||||
text,
|
||||
needs_you: 0,
|
||||
reply_to_message_id: reply_to?.id || undefined,
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -80,8 +87,16 @@ export default async function insertUserMessage({
|
||||
author: "user",
|
||||
text,
|
||||
needs_you: 0,
|
||||
reply_to_message_id: reply_to?.id || undefined,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
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) || [];
|
||||
}
|
||||
@@ -74,7 +74,7 @@ export default async function grabBotChatTools({
|
||||
}: Params): Promise<HarnessTool[]> {
|
||||
const bot_id = bot.id || 0;
|
||||
const tools = [
|
||||
...grabBotTools({ user_id, bot_id, workspace_id, computers }),
|
||||
...grabBotTools({ user_id, bot_id, workspace_id, computers, session_id }),
|
||||
startTaskTool({
|
||||
user_id,
|
||||
bot_id,
|
||||
|
||||
@@ -7,6 +7,7 @@ import setWorkspaceComputerTool from "./set-workspace-computer-tool";
|
||||
import updateScheduleTool from "./update-schedule-tool";
|
||||
import updateInstructionsTool from "./update-instructions-tool";
|
||||
import updateMemoryTool from "./update-memory-tool";
|
||||
import reactToMessageTool from "./react-to-message-tool";
|
||||
|
||||
type Params = {
|
||||
user_id: number | string;
|
||||
@@ -20,6 +21,10 @@ type Params = {
|
||||
* The bot's computers where it runs, the current one first.
|
||||
*/
|
||||
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,
|
||||
workspace_id,
|
||||
computers = [],
|
||||
session_id,
|
||||
}: Params): HarnessTool[] {
|
||||
return [
|
||||
updateMemoryTool({ user_id, bot_id, workspace_id }),
|
||||
@@ -40,6 +46,7 @@ export default function grabBotTools({
|
||||
listSchedulesTool({ user_id, bot_id }),
|
||||
updateScheduleTool({ user_id, bot_id }),
|
||||
deleteScheduleTool({ user_id, bot_id }),
|
||||
...(session_id ? [reactToMessageTool({ bot_id, user_id, session_id })] : []),
|
||||
...(workspace_id && computers.length > 1
|
||||
? [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}.`;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
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";
|
||||
|
||||
type Params = {
|
||||
@@ -9,7 +10,8 @@ type Params = {
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
const rows =
|
||||
@@ -21,9 +23,23 @@ export default async function relayNewBotMessages({ after_id, users }: Params) {
|
||||
})
|
||||
).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) {
|
||||
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,
|
||||
text,
|
||||
media_ids,
|
||||
reply_to_message_id,
|
||||
}: SendBotMessageRequest) {
|
||||
return await fetchApi<
|
||||
SendBotMessageRequest,
|
||||
APIResponseObject<BotMessageWithAttachments>
|
||||
>(`/api/admin/bots/send-message`, {
|
||||
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) => (
|
||||
<BotMessageLine
|
||||
key={message.id}
|
||||
message_id={message.id || null}
|
||||
text={message.text || ""}
|
||||
needs_you={Boolean(message.needs_you)}
|
||||
message={message}
|
||||
bot_name={bot.name || "the bot"}
|
||||
at={index == group.messages.length - 1 ? group.at : undefined}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -1,17 +1,22 @@
|
||||
import { useContext } from "react";
|
||||
import Markdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import Row from "@moduletrace/twui/layout/Row";
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
import type { BotMessageWithAttachments } from "@/src/types";
|
||||
import linkComputerRefs from "@/src/utils/link-computer-refs";
|
||||
import { BotMessageActionsContext } from "../(data)/message-actions-context";
|
||||
import { MessageMarkdownComponents } from "../(data)/message-markdown-components";
|
||||
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 SpeakMessageButton from "./speak-message-button";
|
||||
|
||||
type Props = {
|
||||
message_id?: number | null;
|
||||
text: string;
|
||||
needs_you?: boolean;
|
||||
message: BotMessageWithAttachments;
|
||||
bot_name: string;
|
||||
/**
|
||||
* The group's time, on the last message only: shown beside its play
|
||||
* button.
|
||||
@@ -22,32 +27,40 @@ type Props = {
|
||||
/**
|
||||
* One bot message, rendered from markdown (GFM; raw HTML is never
|
||||
* rendered). Computers it names (`computerRef`) show as buttons that
|
||||
* switch the bot to them. A play button under it reads it aloud,
|
||||
* disabled while the bot has no speech model, with the group's time
|
||||
* before it on the last message.
|
||||
* switch the bot to them. A quoted reply sits above it, its reactions
|
||||
* below, and a row under it replies, reacts, reads it aloud and shows
|
||||
* why it needs you.
|
||||
*/
|
||||
export default function BotMessageLine({ message_id, text, needs_you, at }: Props) {
|
||||
const can_speak = Boolean(message_id && text.trim());
|
||||
export default function BotMessageLine({ message, bot_name, at }: Props) {
|
||||
const { onReact } = useContext(BotMessageActionsContext);
|
||||
const can_speak = Boolean(message.id && message.text?.trim());
|
||||
|
||||
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">
|
||||
<Markdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={MessageMarkdownComponents}
|
||||
>
|
||||
{linkComputerRefs({ text })}
|
||||
{linkComputerRefs({ text: message.text || "" })}
|
||||
</Markdown>
|
||||
</Stack>
|
||||
{needs_you || can_speak || at ? (
|
||||
<Row className="gap-2! items-center">
|
||||
{needs_you ? <NeedsYouChip /> : null}
|
||||
{at ? <GroupTimestamp at={at} /> : null}
|
||||
{can_speak ? (
|
||||
<SpeakMessageButton message_id={message_id!} />
|
||||
) : null}
|
||||
</Row>
|
||||
{message.id ? (
|
||||
<MessageReactions
|
||||
message_id={message.id}
|
||||
reactions={message.reactions}
|
||||
onReact={onReact}
|
||||
/>
|
||||
) : null}
|
||||
<Row className="gap-2! items-center">
|
||||
{message.needs_you ? <NeedsYouChip /> : null}
|
||||
{at ? <GroupTimestamp at={at} /> : null}
|
||||
{can_speak ? <SpeakMessageButton message_id={Number(message.id)} /> : null}
|
||||
<MessageActions message={message} />
|
||||
</Row>
|
||||
</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 Stack from "@moduletrace/twui/layout/Stack";
|
||||
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 ComposerAttachButton from "./composer-attach-button";
|
||||
import ComposerAttachments from "./composer-attachments";
|
||||
import ComposerReplyPreview from "./composer-reply-preview";
|
||||
import ComposerSendButton from "./composer-send-button";
|
||||
import ComposerTools from "./composer-tools";
|
||||
import ComposerVoiceButton from "./composer-voice-button";
|
||||
@@ -36,6 +37,11 @@ type Props = {
|
||||
* The dictation mic is disabled while transcription has no model.
|
||||
*/
|
||||
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;
|
||||
};
|
||||
|
||||
@@ -48,7 +54,15 @@ type Props = {
|
||||
* are listed under the input. The mic dictates: what's said is written
|
||||
* 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 [sending, setSending] = useState(false);
|
||||
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 });
|
||||
|
||||
/**
|
||||
* 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({
|
||||
onRecorded: async ({ audio }) => {
|
||||
if (!bot.id) return;
|
||||
@@ -115,6 +136,7 @@ export default function Composer({ bot, workspace_id, extensions, voice, onSent
|
||||
workspace_id,
|
||||
text,
|
||||
media_ids: files.media_ids,
|
||||
reply_to_message_id: reply_to?.id || null,
|
||||
});
|
||||
|
||||
setSending(false);
|
||||
@@ -156,7 +178,14 @@ export default function Composer({ bot, workspace_id, extensions, voice, onSent
|
||||
onRemove={files.remove}
|
||||
/>
|
||||
<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) => {
|
||||
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) {
|
||||
if (group.author === "user") {
|
||||
return <UserMessageGroup group={group} />;
|
||||
return <UserMessageGroup 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 type { BotWithLastMessage } from "@/src/types";
|
||||
import type { MessageGroup } from "../(utils)/group-messages";
|
||||
import UserMessage from "./user-message";
|
||||
import GroupTimestamp from "./group-timestamp";
|
||||
|
||||
type Props = {
|
||||
bot: BotWithLastMessage;
|
||||
group: MessageGroup;
|
||||
};
|
||||
|
||||
/**
|
||||
* Right-aligned ink bubbles (each message's files above it), one
|
||||
* timestamp under the group.
|
||||
*/
|
||||
export default function UserMessageGroup({ group }: { group: MessageGroup }) {
|
||||
export default function UserMessageGroup({ bot, group }: Props) {
|
||||
return (
|
||||
<Stack className="w-full gap-1! items-end">
|
||||
{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} />
|
||||
</Stack>
|
||||
|
||||
@@ -1,16 +1,44 @@
|
||||
import { useContext } from "react";
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
import type { BotMessageWithAttachments } from "@/src/types";
|
||||
import { BotMessageActionsContext } from "../(data)/message-actions-context";
|
||||
import MessageActions from "./message-actions";
|
||||
import MessageAttachments from "./message-attachments";
|
||||
import MessageReactions from "./message-reactions";
|
||||
import MessageReplyQuote from "./message-reply-quote";
|
||||
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
|
||||
* bubble).
|
||||
* One user message: the quoted message it answers, its files, then its
|
||||
* 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 (
|
||||
<>
|
||||
<MessageAttachments attachments={attachments} />
|
||||
{text ? <UserBubble text={text} /> : null}
|
||||
</>
|
||||
<Stack className="w-full min-w-0 max-w-full items-end gap-1.5!">
|
||||
{message.reply_to ? (
|
||||
<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 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 fetchApi from "@moduletrace/twui/utils/fetch/fetchApi";
|
||||
import useLiveBots from "@/src/hooks/use-live-bots";
|
||||
import useServerEvent from "@/src/hooks/use-server-event";
|
||||
import { BotComputersContext } from "../(data)/bot-computers-context";
|
||||
import { BotMessageActionsContext } from "../(data)/message-actions-context";
|
||||
import { SpeakMessageContext } from "../(data)/speak-message-context";
|
||||
import reactToBotMessage from "../(functions)/react-to-bot-message";
|
||||
import useBotComputers from "../(hooks)/use-bot-computers";
|
||||
import useTaskProgress from "../(hooks)/use-task-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 [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({
|
||||
bots: pageProps?.bots || [],
|
||||
@@ -113,12 +127,44 @@ 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;
|
||||
|
||||
return (
|
||||
<BotComputersContext.Provider value={bot_computers}>
|
||||
<SpeakMessageContext.Provider value={speak}>
|
||||
<Stack className="grow min-w-0 h-full gap-0! items-stretch">
|
||||
<BotMessageActionsContext.Provider
|
||||
value={{
|
||||
onReply: ({ message }) => setReplyTo(message),
|
||||
onReact: reactToMessage,
|
||||
}}
|
||||
>
|
||||
<Stack className="grow min-w-0 h-full gap-0! items-stretch">
|
||||
<ConversationHeader
|
||||
bot={live_bot}
|
||||
workspace={workspace}
|
||||
@@ -137,7 +183,11 @@ export default function Conversation() {
|
||||
workspace_id={workspace_id}
|
||||
extensions={pageProps?.bot_extensions}
|
||||
voice={pageProps?.bot_voice}
|
||||
reply_to={reply_to}
|
||||
onCancelReply={() => setReplyTo(null)}
|
||||
onSent={({ message }) => {
|
||||
setReplyTo(null);
|
||||
|
||||
if (startsNewSession(message) && message.session_id != session_id) {
|
||||
setSessionId(Number(message.session_id));
|
||||
setMessages([message]);
|
||||
@@ -151,7 +201,8 @@ export default function Conversation() {
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</BotMessageActionsContext.Provider>
|
||||
<ComputerViewerModal bot={live_bot} />
|
||||
</SpeakMessageContext.Provider>
|
||||
</BotComputersContext.Provider>
|
||||
|
||||
@@ -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 }) };
|
||||
}
|
||||
};
|
||||
@@ -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 ensureBotSession from "@/src/functions/backend/db/bot-sessions/ensure-bot-session";
|
||||
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 enqueueBotReply from "@/src/functions/backend/db/bots-queue/enqueue-bot-reply";
|
||||
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.`);
|
||||
}
|
||||
|
||||
const { bot_id, workspace_id, text, media_ids } = parseWithSchema({
|
||||
const { bot_id, workspace_id, text, media_ids, reply_to_message_id } = parseWithSchema({
|
||||
schema: SendBotMessageRequestSchema,
|
||||
data: body,
|
||||
});
|
||||
@@ -63,11 +64,32 @@ export const handler: BunextAPIRouteHandler<APIResponseObject> = async ({
|
||||
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({
|
||||
bot_id: bot.id,
|
||||
session_id: Number(session.id),
|
||||
workspace_id: conversation.workspace_id,
|
||||
text,
|
||||
reply_to,
|
||||
media,
|
||||
});
|
||||
|
||||
|
||||
@@ -43,6 +43,7 @@ export const handler: BunextAPIRouteHandler<
|
||||
payload: await grabBotMessages({
|
||||
session_id: session.id,
|
||||
limit: AppData.BotReplyCatchUpMessages,
|
||||
user_id: user.id,
|
||||
}),
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { z } from "zod";
|
||||
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
|
||||
@@ -16,10 +21,29 @@ export const SendBotMessageRequestSchema = z.object({
|
||||
.array(z.coerce.number().int().positive())
|
||||
.max(AppData.BotAttachmentMaxFiles, `Attach up to ${AppData.BotAttachmentMaxFiles} files`)
|
||||
.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");
|
||||
|
||||
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({
|
||||
bot_id: z.coerce.number().int().positive("Pick a bot"),
|
||||
});
|
||||
|
||||
+36
-1
@@ -26,7 +26,7 @@ import type { HarnessKind } from "../dict/harness-dict";
|
||||
import type { HarnessProviderKind } from "../dict/harness-provider-dict";
|
||||
import type { EnvParadigm, KnownEnv } from "../dict/env-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 {
|
||||
CustomToolHttpMethod,
|
||||
@@ -587,8 +587,39 @@ export type BotMessageAttachment = {
|
||||
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 & {
|
||||
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 = {
|
||||
@@ -877,6 +908,10 @@ export const WebSocketEvents = [
|
||||
* `WorkspaceRemovedData`)
|
||||
*/
|
||||
"server:workspace-removed",
|
||||
/**
|
||||
* A message's emoji reactions changed (`data`: `BotMessageReactionData`)
|
||||
*/
|
||||
"server:bot-reaction",
|
||||
/**
|
||||
* A running job took a step (`data`: `BotJobUpdateData`)
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user