Let users copy the current chat session as markdown
The conversation header menu gets a Copy chat row that puts the active session on the clipboard as one markdown document: a summary, then each message under its author and time, with quotes, attachments and reactions. Bot and workspace refs read as names, message headings drop below the message headings and code blocks stay untouched. The clipboard write starts on the click with a pending ClipboardItem so Safari allows it after the fetch. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
@@ -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.
|
- **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`).
|
- **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:<volume>:<owner>:<path>]` (opens `/admin/files`), `[take-over:ID:mode]`, `[proposal:ID]`. Parsers in `src/utils/*-ref.ts`.
|
- **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:<volume>:<owner>:<path>]` (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`.
|
- **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.
|
- **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.
|
- **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.
|
||||||
|
|||||||
@@ -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<ConversationExport | null> {
|
||||||
|
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<BUN_SQLITE_COBBLE_BOT_MESSAGES>({
|
||||||
|
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]),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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<ConversationExport>
|
||||||
|
>(`/api/admin/bots/conversation-export`, {
|
||||||
|
method: "POST",
|
||||||
|
body: { bot_id, workspace_id },
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ import BotModelsModal from "./bot-models-modal";
|
|||||||
import BotAdminMenuItem from "./bot-admin-menu-item";
|
import BotAdminMenuItem from "./bot-admin-menu-item";
|
||||||
import HeaderMenuItem from "./header-menu-item";
|
import HeaderMenuItem from "./header-menu-item";
|
||||||
import NewSessionButton from "./new-session-button";
|
import NewSessionButton from "./new-session-button";
|
||||||
|
import CopyChatMenuItem from "./copy-chat-menu-item";
|
||||||
import StopBotButton from "./stop-bot-button";
|
import StopBotButton from "./stop-bot-button";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@@ -54,8 +55,8 @@ type Props = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The conversation header's menu: the sessions, a new session, the bot's
|
* The conversation header's menu: the sessions, a new session, copying
|
||||||
* computer, its models, in a workspace making it the admin there, its
|
* 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
|
* settings and, while it works, stopping it. A
|
||||||
* row that opens a modal closes the menu first, so the modal never sits
|
* row that opens a modal closes the menu first, so the modal never sits
|
||||||
* behind an open menu.
|
* behind an open menu.
|
||||||
@@ -117,6 +118,11 @@ export default function ConversationHeaderMenu({
|
|||||||
workspace_id={workspace?.id || null}
|
workspace_id={workspace?.id || null}
|
||||||
on_open={on_open}
|
on_open={on_open}
|
||||||
/>
|
/>
|
||||||
|
<CopyChatMenuItem
|
||||||
|
bot_id={Number(bot.id)}
|
||||||
|
workspace_id={workspace?.id || null}
|
||||||
|
on_open={on_open}
|
||||||
|
/>
|
||||||
<BotFilesModal
|
<BotFilesModal
|
||||||
menu
|
menu
|
||||||
bot={bot}
|
bot={bot}
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { Check, ClipboardCopy } from "lucide-react";
|
||||||
|
import { AppData } from "@/src/data/app-data";
|
||||||
|
import conversationMarkdown from "@/src/utils/conversation-markdown";
|
||||||
|
import copyPendingText from "@/src/utils/copy-pending-text";
|
||||||
|
import fetchConversationExport from "../(functions)/fetch-conversation-export";
|
||||||
|
import HeaderMenuItem from "./header-menu-item";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
bot_id: number;
|
||||||
|
workspace_id?: number | null;
|
||||||
|
/**
|
||||||
|
* Closes the menu once the copy is confirmed.
|
||||||
|
*/
|
||||||
|
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<ReturnType<typeof setTimeout> | 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 (
|
||||||
|
<HeaderMenuItem
|
||||||
|
icon={copied ? Check : ClipboardCopy}
|
||||||
|
title={copied ? "Copied" : "Copy chat"}
|
||||||
|
description="Copy this session as markdown"
|
||||||
|
loading={loading}
|
||||||
|
disabled={loading}
|
||||||
|
onClick={() => {
|
||||||
|
if (!copied) void copy();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<ConversationExport>
|
||||||
|
> = 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 }) };
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -1326,6 +1326,38 @@ export type ConversationHistory = {
|
|||||||
* where to go instead when the user can't chat with the bot there any
|
* where to go instead when the user can't chat with the bot there any
|
||||||
* more.
|
* 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<number, string>;
|
||||||
|
workspaces: Record<number, string>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ConversationExportSession = {
|
||||||
|
id: number;
|
||||||
|
title: string | null;
|
||||||
|
created_at: number;
|
||||||
|
};
|
||||||
|
|
||||||
export type ConversationHistoryPayload = {
|
export type ConversationHistoryPayload = {
|
||||||
history?: ConversationHistory;
|
history?: ConversationHistory;
|
||||||
redirect?: string;
|
redirect?: string;
|
||||||
|
|||||||
@@ -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:**");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import copyText from "./copy-text";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
/**
|
||||||
|
* The text, still on its way (e.g. from a fetch).
|
||||||
|
*/
|
||||||
|
text: Promise<string>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<boolean> {
|
||||||
|
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 });
|
||||||
|
}
|
||||||
@@ -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~~~~",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
type Params = {
|
||||||
|
text: string;
|
||||||
|
/**
|
||||||
|
* Names by id; a ref to anything missing reads "a bot" / "a workspace".
|
||||||
|
*/
|
||||||
|
bot_names: Record<number, string>;
|
||||||
|
workspace_names: Record<number, string>;
|
||||||
|
/**
|
||||||
|
* 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<number, string>;
|
||||||
|
workspace_names: Record<number, string>;
|
||||||
|
}) {
|
||||||
|
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");
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user