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:
@@ -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 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}
|
||||
/>
|
||||
<CopyChatMenuItem
|
||||
bot_id={Number(bot.id)}
|
||||
workspace_id={workspace?.id || null}
|
||||
on_open={on_open}
|
||||
/>
|
||||
<BotFilesModal
|
||||
menu
|
||||
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();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user