diff --git a/AGENTS.md b/AGENTS.md index 1d3805e..2e7bd01 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -95,6 +95,7 @@ Enables SQLite foreign keys (`enableForeignKeys`), `bunextInit()`, `ensureDocker - **Sending:** `api/admin/bots/send-message.ts` -> `enqueueBotReply` -> `wakeBotsQueue`. **Never import `src/bots-queue/index.ts` from a page or API route** (pulls the bunext runtime into page bundles). Optimistic bubble in `pending_messages` until the saved message replaces it. - **Composer:** Tiptap editor reading/writing markdown, drafts in `localStorage`, `@` bots / `#` workspaces written as `[bot:ID]`/`[workspace:ID]`. Enter sends (Mod+Enter in lists/code). `@tiptap/react` needs `use-sync-external-store`; the app ships an ESM stand-in in `packages/use-sync-external-store-esm` (file dependency + override; the Dockerfile copies `packages/` before `bun install`). - **Rendering:** markdown via `react-markdown` + `remark-gfm`, raw HTML never rendered, component map in `[bot_id]/(data)/message-markdown-components.tsx`. Inline refs, all linked by `link-computer-refs.ts` and rendered by chips in `(partials)/`: `[bot:ID]`, `[workspace:ID]`, `[computer:ID]`, `[file:ID]` (a saved file), `[folder:::]` (opens `/admin/files`), `[take-over:ID:mode]`, `[proposal:ID]`. Parsers in `src/utils/*-ref.ts`. +- **Copy chat** (header menu): the active session (the one on screen) as one markdown document (`api/admin/bots/conversation-export` -> `conversationMarkdown`, refs as names, message headings demoted). The clipboard write starts on the click with a pending `ClipboardItem` (`copyPendingText`), since Safari drops the gesture after a fetch. - **Replies/reactions:** `reply_to_message_id` (not an FK); reactions from the fixed `BotMessageReactions`, toggled by `react-message.ts` and the `react_to_message` tool, pushed as `server:bot-reaction`. - **Attachments:** uploaded on pick (`upload-attachment.ts`, private `media`, text extracted when possible), linked via `bot_message_media`. Replies read files of the last `BotReplyAttachmentMessages` messages. Tasks don't get files yet. - **Thinking row / stop:** `bot-busy-state.ts` decides the row's line and whether Stop shows. Stop (`api/admin/bots/stop.ts`, `stop-bot-jobs`) fails every open job of that bot for the user, aborts the running handler (`job-abort-registry`) and drops late face updates. diff --git a/src/functions/backend/db/bot-sessions/grab-conversation-export.ts b/src/functions/backend/db/bot-sessions/grab-conversation-export.ts new file mode 100644 index 0000000..c1fe49b --- /dev/null +++ b/src/functions/backend/db/bot-sessions/grab-conversation-export.ts @@ -0,0 +1,102 @@ +import BunSQLite from "@moduletrace/bun-sqlite"; +import type { BUN_SQLITE_COBBLE_BOT_MESSAGES } from "@/db/types/db"; +import type { ConversationExport } from "@/src/types"; +import grabMessageRefNames from "../bots/grab-message-ref-names"; +import grabMessagesMedia from "../bots/grab-messages-media"; +import grabMessagesReactions from "../bots/grab-messages-reactions"; +import grabMessagesReplies from "../bots/grab-messages-replies"; +import mediaToAttachment from "../bots/media-to-attachment"; +import grabActiveBotSession from "./grab-active-bot-session"; +import grabUserConversation from "./grab-user-conversation"; + +type Params = { + user_id: number | string; + /** + * Who the user messages are from in the copy. + */ + user_name: string; + bot_id: number | string; + /** + * The workspace the conversation is in; none for the bot's own chat. + */ + workspace_id?: number | string | null; +}; + +/** + * The session the conversation page shows (its active one), for "Copy + * chat": every message in it, oldest first, with their attachments, + * quotes and reactions, and the names of the bots and workspaces they + * mention. `grabUserConversation` is the access check. Null when the + * user can't chat with the bot there. + */ +export default async function grabConversationExport({ + user_id, + user_name, + bot_id, + workspace_id, +}: Params): Promise { + const conversation = await grabUserConversation({ user_id, bot_id, workspace_id }).catch( + () => null, + ); + + if (!conversation?.bot?.id) return null; + + const session = await grabActiveBotSession({ + user_id, + bot_id: conversation.bot.id, + workspace_id: conversation.workspace_id, + }); + + const messages = session?.id + ? ( + await BunSQLite.sql({ + sql: `SELECT * FROM bot_messages WHERE session_id = ? ORDER BY created_at ASC, id ASC`, + values: [session.id], + }) + ).payload || [] + : []; + + const message_ids = messages.map((message) => Number(message.id)); + + const [media, replies, reactions, names] = await Promise.all([ + grabMessagesMedia({ message_ids }), + grabMessagesReplies({ message_ids }), + grabMessagesReactions({ message_ids, user_id }), + grabMessageRefNames({ + texts: messages.map((message) => message.text || ""), + user_id, + }), + ]); + + return { + bot_name: conversation.bot.name || "Bot", + workspace_name: conversation.workspace?.name || null, + user_name, + session: session?.id + ? { + id: Number(session.id), + title: session.title || null, + created_at: Number(session.created_at), + } + : null, + messages: messages.map((message) => { + const id = Number(message.id); + const files = media.get(id); + + return { + ...message, + attachments: files?.map((file) => mediaToAttachment({ media: file })), + reply_to: replies.get(id) || null, + reactions: reactions.get(id) || [], + }; + }), + ref_names: { + bots: Object.fromEntries( + [...names.bots].map(([id, bot]) => [id, bot.name]), + ), + workspaces: Object.fromEntries( + [...names.workspaces].map(([id, workspace]) => [id, workspace.name]), + ), + }, + }; +} diff --git a/src/pages/admin/bots/[bot_id]/(functions)/fetch-conversation-export.ts b/src/pages/admin/bots/[bot_id]/(functions)/fetch-conversation-export.ts new file mode 100644 index 0000000..8e47b22 --- /dev/null +++ b/src/pages/admin/bots/[bot_id]/(functions)/fetch-conversation-export.ts @@ -0,0 +1,20 @@ +import fetchApi from "@moduletrace/twui/utils/fetch/fetchApi"; +import type { APIResponseObject } from "@moduletrace/bunext/types"; +import type { BotConversationRequest } from "@/src/schemas/bot-schema"; +import type { ConversationExport } from "@/src/types"; + +/** + * The conversation's active session, for "Copy chat". + */ +export default async function fetchConversationExport({ + bot_id, + workspace_id, +}: BotConversationRequest) { + return await fetchApi< + BotConversationRequest, + APIResponseObject + >(`/api/admin/bots/conversation-export`, { + method: "POST", + body: { bot_id, workspace_id }, + }); +} diff --git a/src/pages/admin/bots/[bot_id]/(partials)/conversation-header-menu.tsx b/src/pages/admin/bots/[bot_id]/(partials)/conversation-header-menu.tsx index e9d40f5..e3d17f2 100644 --- a/src/pages/admin/bots/[bot_id]/(partials)/conversation-header-menu.tsx +++ b/src/pages/admin/bots/[bot_id]/(partials)/conversation-header-menu.tsx @@ -17,6 +17,7 @@ import BotModelsModal from "./bot-models-modal"; import BotAdminMenuItem from "./bot-admin-menu-item"; import HeaderMenuItem from "./header-menu-item"; import NewSessionButton from "./new-session-button"; +import CopyChatMenuItem from "./copy-chat-menu-item"; import StopBotButton from "./stop-bot-button"; type Props = { @@ -54,8 +55,8 @@ type Props = { }; /** - * The conversation header's menu: the sessions, a new session, the bot's - * computer, its models, in a workspace making it the admin there, its + * The conversation header's menu: the sessions, a new session, copying + * the session, the bot's computer, its models, in a workspace making it the admin there, its * settings and, while it works, stopping it. A * row that opens a modal closes the menu first, so the modal never sits * behind an open menu. @@ -117,6 +118,11 @@ export default function ConversationHeaderMenu({ workspace_id={workspace?.id || null} on_open={on_open} /> + void; +}; + +/** + * The header menu's "Copy chat": puts the session on screen on the + * clipboard as one markdown document (`conversationMarkdown`). The row shows a check for a moment, then the + * menu closes. + */ +export default function CopyChatMenuItem({ bot_id, workspace_id, on_open }: Props) { + const [loading, setLoading] = useState(false); + const [copied, setCopied] = useState(false); + const timeout = useRef | null>(null); + + useEffect(() => { + return () => { + if (timeout.current) clearTimeout(timeout.current); + }; + }, []); + + const copy = async () => { + setLoading(true); + + const markdown = fetchConversationExport({ bot_id, workspace_id }).then((res) => { + if (!res?.success || !res.singleRes) { + throw new Error(res?.msg || "Couldn't load the chat."); + } + + return conversationMarkdown({ conversation: res.singleRes, now: Date.now() }); + }); + + try { + const worked = await copyPendingText({ text: markdown }); + + setLoading(false); + + if (!worked) { + window.alert("Couldn't copy the chat to the clipboard."); + return; + } + + setCopied(true); + timeout.current = setTimeout(() => { + setCopied(false); + on_open?.(); + }, AppData.CopyConfirmationMs); + } catch (error: unknown) { + setLoading(false); + window.alert(error instanceof Error ? error.message : "Couldn't copy the chat."); + } + }; + + return ( + { + if (!copied) void copy(); + }} + /> + ); +} diff --git a/src/pages/api/admin/bots/conversation-export.ts b/src/pages/api/admin/bots/conversation-export.ts new file mode 100644 index 0000000..127e70f --- /dev/null +++ b/src/pages/api/admin/bots/conversation-export.ts @@ -0,0 +1,56 @@ +import userAuth from "@/src/functions/backend/auth/user-auth"; +import grabConversationExport from "@/src/functions/backend/db/bot-sessions/grab-conversation-export"; +import { BotConversationRequestSchema } from "@/src/schemas/bot-schema"; +import type { ConversationExport } from "@/src/types"; +import AppError from "@/src/utils/app-error"; +import parseWithSchema from "@/src/utils/parse-with-schema"; +import safeErrorMessage from "@/src/utils/safe-error-message"; +import type { + APIResponseObject, + BunextAPIRouteHandler, +} from "@moduletrace/bunext/types"; + +/** + * The active session of the session user's conversation with a bot, which + * the header menu's "Copy chat" turns into markdown. + */ +export const handler: BunextAPIRouteHandler< + APIResponseObject +> = async ({ req, body }) => { + try { + if (req.method != "POST") { + throw new AppError(`Method not allowed`); + } + + const { user } = await userAuth({ req }); + + if (!user?.logged_in_status || !user.id) { + return { success: false, msg: `Unauthorized`, logoutUser: true }; + } + + const { bot_id, workspace_id } = parseWithSchema({ + schema: BotConversationRequestSchema, + data: body, + }); + + const user_name = + [user.first_name, user.last_name].filter(Boolean).join(" ") || + user.username || + "You"; + + const conversation = await grabConversationExport({ + user_id: Number(user.id), + user_name, + bot_id, + workspace_id, + }); + + if (!conversation) { + throw new AppError(`You can't open this conversation.`); + } + + return { success: true, singleRes: conversation }; + } catch (error: unknown) { + return { success: false, msg: safeErrorMessage({ error }) }; + } +}; diff --git a/src/types/index.ts b/src/types/index.ts index b0a1e18..4a10752 100755 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1326,6 +1326,38 @@ export type ConversationHistory = { * where to go instead when the user can't chat with the bot there any * more. */ +/** + * A conversation's active session for "Copy chat" + * (`grabConversationExport`): its messages, oldest first, and the names of + * the bots and workspaces they mention. + */ +export type ConversationExport = { + bot_name: string; + workspace_name: string | null; + /** + * Who the user messages are from. + */ + user_name: string; + /** + * Null while the conversation has no session yet. + */ + session: ConversationExportSession | null; + messages: BotMessageWithAttachments[]; + /** + * Names by id, only for what the user reaches. + */ + ref_names: { + bots: Record; + workspaces: Record; + }; +}; + +export type ConversationExportSession = { + id: number; + title: string | null; + created_at: number; +}; + export type ConversationHistoryPayload = { history?: ConversationHistory; redirect?: string; diff --git a/src/utils/conversation-markdown.test.ts b/src/utils/conversation-markdown.test.ts new file mode 100644 index 0000000..92c4217 --- /dev/null +++ b/src/utils/conversation-markdown.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, test } from "bun:test"; +import type { ConversationExport } from "@/src/types"; +import conversationMarkdown from "./conversation-markdown"; + +const format_time = (time: number) => `t${time}`; + +const conversation: ConversationExport = { + bot_name: "Ada", + workspace_name: "Launch", + user_name: "Ben", + session: { id: 1, title: "Kickoff", created_at: 10 }, + messages: [ + { id: 1, session_id: 1, author: "user", text: "Hi [bot:3]", created_at: 11 }, + { + id: 2, + session_id: 1, + author: "bot", + text: "## Plan\n\nDone.", + created_at: 12, + attachments: [{ id: 5, name: "plan.pdf", media_type: "document", url: "" }], + reactions: [{ emoji: "👍", count: 2, mine: true }], + }, + { + id: 3, + session_id: 1, + author: "user", + text: "Thanks", + created_at: 13, + reply_to: { id: 2, author: "bot", text: "## Plan\n\nDone." }, + }, + ], + ref_names: { bots: { 3: "Ada" }, workspaces: {} }, +}; + +describe("conversationMarkdown", () => { + test("lays out the session's messages", () => { + expect(conversationMarkdown({ conversation, now: 100, format_time })).toBe( + [ + "# Kickoff", + "", + "- **Bot:** Ada", + "- **Workspace:** Launch", + "- **Participants:** Ben, Ada", + "- **Started:** t10", + "- **Messages:** 3", + "- **Copied:** t100", + "", + "---", + "", + "## Ben · t11", + "", + "Hi @Ada", + "", + "## Ada · t12", + "", + "#### Plan", + "", + "Done.", + "", + "*Attached: plan.pdf*", + "", + "*Reactions: 👍 2*", + "", + "## Ben · t13", + "", + "> Replying to Ada: Plan Done.", + "", + "Thanks", + "", + ].join("\n"), + ); + }); + + test("titles an untitled or empty session after the bot", () => { + const markdown = conversationMarkdown({ + conversation: { ...conversation, session: null, messages: [] }, + now: 1, + format_time, + }); + + expect(markdown).toStartWith("# Chat with Ada\n"); + expect(markdown).toContain("*No messages yet.*"); + expect(markdown).not.toContain("**Started:**"); + }); +}); diff --git a/src/utils/conversation-markdown.ts b/src/utils/conversation-markdown.ts new file mode 100644 index 0000000..ba9021f --- /dev/null +++ b/src/utils/conversation-markdown.ts @@ -0,0 +1,105 @@ +import type { BotMessageWithAttachments, ConversationExport } from "@/src/types"; +import exportMessageText from "./export-message-text"; +import messagePreview from "./message-preview"; + +type Params = { + conversation: ConversationExport; + /** + * When the copy was made. + */ + now: number; + /** + * A timestamp as the reader sees it; local time by default. + */ + format_time?: (time: number) => string; +}; + +/** + * Longest a quoted message runs in a reply's "Replying to" line. + */ +const QuoteMaxChars = 120; + +/** + * A conversation's session (`grabConversationExport`) as one markdown + * document: a title and summary, then every message under its author and + * time. Messages' own headings drop below those (`exportMessageText`) and + * their refs read as names. + */ +export default function conversationMarkdown({ + conversation, + now, + format_time = (time) => + new Date(time).toLocaleString(undefined, { + dateStyle: "medium", + timeStyle: "short", + }), +}: Params) { + const { bot_name, workspace_name, user_name, session, messages, ref_names } = conversation; + + const authorName = (author?: string) => (author == "bot" ? bot_name : user_name); + + const messageBlock = (message: BotMessageWithAttachments) => { + const lines = [`## ${authorName(message.author)} · ${format_time(Number(message.created_at))}`, ""]; + + if (message.reply_to) { + const quote = messagePreview({ text: message.reply_to.text || "" }); + + lines.push( + `> Replying to ${authorName(message.reply_to.author)}: ${ + quote.length > QuoteMaxChars ? `${quote.slice(0, QuoteMaxChars).trimEnd()}...` : quote + }`, + "", + ); + } + + const text = exportMessageText({ + text: message.text || "", + bot_names: ref_names.bots, + workspace_names: ref_names.workspaces, + heading_offset: 2, + }); + + if (text) lines.push(text, ""); + + if (message.attachments?.length) { + lines.push(`*Attached: ${message.attachments.map((file) => file.name).join(", ")}*`, ""); + } + + if (message.reactions?.length) { + lines.push( + `*Reactions: ${message.reactions + .map((reaction) => (reaction.count > 1 ? `${reaction.emoji} ${reaction.count}` : reaction.emoji)) + .join(" ")}*`, + "", + ); + } + + return lines.join("\n"); + }; + + const out: string[] = [ + `# ${session?.title?.trim() || `Chat with ${bot_name}`}`, + "", + `- **Bot:** ${bot_name}`, + ]; + + if (workspace_name) out.push(`- **Workspace:** ${workspace_name}`); + + out.push(`- **Participants:** ${user_name}, ${bot_name}`); + + if (session) out.push(`- **Started:** ${format_time(session.created_at)}`); + + out.push( + `- **Messages:** ${messages.length}`, + `- **Copied:** ${format_time(now)}`, + "", + "---", + "", + ); + + if (!messages.length) out.push("*No messages yet.*", ""); + + out.push(...messages.map(messageBlock)); + + return `${out.join("\n").trimEnd()}\n`; +} diff --git a/src/utils/copy-pending-text.ts b/src/utils/copy-pending-text.ts new file mode 100644 index 0000000..ebe4255 --- /dev/null +++ b/src/utils/copy-pending-text.ts @@ -0,0 +1,33 @@ +import copyText from "./copy-text"; + +type Params = { + /** + * The text, still on its way (e.g. from a fetch). + */ + text: Promise; +}; + +/** + * Copies text that is still loading and says whether it worked. Safari + * only lets a click write to the clipboard while it is still handling + * the click, so the write starts at once with a `ClipboardItem` that + * waits for the text. Where that isn't supported it waits and falls back + * to `copyText`. Throws when `text` does. + */ +export default async function copyPendingText({ text }: Params): Promise { + if (typeof ClipboardItem != "undefined" && navigator.clipboard?.write) { + try { + await navigator.clipboard.write([ + new ClipboardItem({ + "text/plain": text.then((value) => new Blob([value], { type: "text/plain" })), + }), + ]); + + return true; + } catch { + // Fall through: `text` failing rethrows below. + } + } + + return copyText({ text: await text }); +} diff --git a/src/utils/export-message-text.test.ts b/src/utils/export-message-text.test.ts new file mode 100644 index 0000000..adb1219 --- /dev/null +++ b/src/utils/export-message-text.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, test } from "bun:test"; +import exportMessageText from "./export-message-text"; + +const names = { bot_names: { 3: "Ada" }, workspace_names: { 2: "Launch" } }; + +describe("exportMessageText", () => { + test("words refs, naming what is known", () => { + expect( + exportMessageText({ + text: "[bot:3] and [bot:9] in [workspace:2] on [computer:4], see [file:12] and [folder:computer:4:seo/raw]. [take-over:4:browser]", + ...names, + heading_offset: 0, + }), + ).toBe("@Ada and a bot in #Launch on computer 4, see file 12 and `seo/raw`. (take over computer 4, browser)"); + }); + + test("leaves refs that are already links alone", () => { + expect(exportMessageText({ text: "[bot:3](#x)", ...names, heading_offset: 0 })).toBe("[bot:3](#x)"); + }); + + test("drops headings, capped at six", () => { + expect( + exportMessageText({ text: "# Top\n## Next\n##### Deep\n#nottag", ...names, heading_offset: 3 }), + ).toBe("#### Top\n##### Next\n###### Deep\n#nottag"); + }); + + test("leaves code blocks exactly as they are", () => { + const text = "```bash\n# comment [bot:3]\n```\n# Title\n~~~~\n## also code\n~~~\n~~~~"; + + expect(exportMessageText({ text, ...names, heading_offset: 3 })).toBe( + "```bash\n# comment [bot:3]\n```\n#### Title\n~~~~\n## also code\n~~~\n~~~~", + ); + }); +}); diff --git a/src/utils/export-message-text.ts b/src/utils/export-message-text.ts new file mode 100644 index 0000000..2b92ccd --- /dev/null +++ b/src/utils/export-message-text.ts @@ -0,0 +1,87 @@ +type Params = { + text: string; + /** + * Names by id; a ref to anything missing reads "a bot" / "a workspace". + */ + bot_names: Record; + workspace_names: Record; + /** + * How many levels the message's own headings drop, so they sit under + * the heading of the message they are in. Capped at `######`. + */ + heading_offset: number; +}; + +/** + * A message's markdown as it reads outside the app: its refs in words + * (`[bot:3]` as `@Ada`, `[workspace:2]` as `#Launch`, `[computer:3]` as + * "computer 3", a folder as its path) and its headings dropped by + * `heading_offset`. Code blocks are left exactly as they are. + */ +export default function exportMessageText({ + text, + bot_names, + workspace_names, + heading_offset, +}: Params) { + let fence: string | null = null; + + return text + .split("\n") + .map((line) => { + const marker = line.match(/^\s{0,3}(`{3,}|~{3,})/)?.[1]; + + if (fence) { + if (marker && marker[0] == fence[0] && marker.length >= fence.length) { + fence = null; + } + + return line; + } + + if (marker) { + fence = marker; + return line; + } + + return wordRefs({ line, bot_names, workspace_names }).replace( + /^(\s{0,3})(#{1,6})(?=\s|$)/, + (_, indent: string, hashes: string) => + `${indent}${"#".repeat(Math.min(6, hashes.length + heading_offset))}`, + ); + }) + .join("\n") + .trim(); +} + +/** + * One line's refs in words. Refs that are already markdown links + * (`[bot:3](...)`) are left alone, as the chat leaves them. + */ +function wordRefs({ + line, + bot_names, + workspace_names, +}: { + line: string; + bot_names: Record; + workspace_names: Record; +}) { + return line + .replace(/\[bot:(\d+)\](?!\()/g, (_, id: string) => { + const name = bot_names[Number(id)]; + + return name ? `@${name}` : "a bot"; + }) + .replace(/\[workspace:(\d+)\](?!\()/g, (_, id: string) => { + const name = workspace_names[Number(id)]; + + return name ? `#${name}` : "a workspace"; + }) + .replace(/\[computer:(\d+)\](?!\()/g, "computer $1") + .replace(/\[take-over:(\d+):([a-z]+)\](?!\()/g, "(take over computer $1, $2)") + .replace(/\[proposal:(\d+)\](?!\()/g, "proposal $1") + .replace(/\[file:(\d+)\](?!\()/g, "file $1") + .replace(/\[folder:[a-z]+:\d+:([^\]\n]+)\](?!\()/g, "`$1`") + .replace(/\[folder:([a-z]+):(\d+)\](?!\()/g, "the $1 $2 folder"); +}