🚑 fix(export): Issue exporting Conversation with Assistants (#2769)

* 🚑 fix(export): use content as text if content is present in the message

If the endpoint is assistants, the text of the message goes into content, not message.text.

* refactor(ExportModel): TypeScript, remove unused code

---------

Co-authored-by: Yuichi Ohneda <ohneda@gmail.com>
This commit is contained in:
Danny Avila 2024-05-17 14:10:40 -04:00 committed by GitHub
parent 31479d6a48
commit 53fe2f6453
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 627 additions and 493 deletions

View file

@ -1,44 +0,0 @@
import { useState, forwardRef } from 'react';
import { useRecoilValue } from 'recoil';
import { Download } from 'lucide-react';
import ExportModal from './ExportModal';
import { useLocalize } from '~/hooks';
import { cn } from '~/utils/';
import store from '~/store';
const ExportConversation = forwardRef(() => {
const [open, setOpen] = useState(false);
const localize = useLocalize();
const conversation = useRecoilValue(store.conversation) || {};
const exportable =
conversation?.conversationId &&
conversation?.conversationId !== 'new' &&
conversation?.conversationId !== 'search';
const clickHandler = () => {
if (exportable) {
setOpen(true);
}
};
return (
<>
<button
className={cn(
'flex w-full cursor-pointer items-center gap-3 px-3 py-3 text-sm text-white transition-colors duration-200 hover:bg-gray-700',
exportable ? 'cursor-pointer text-white' : 'cursor-not-allowed text-gray-400',
)}
onClick={clickHandler}
>
<Download size={16} />
{localize('com_nav_export_conversation')}
</button>
<ExportModal open={open} onOpenChange={setOpen} />
</>
);
});
export default ExportConversation;

View file

@ -1,448 +0,0 @@
import download from 'downloadjs';
import filenamify from 'filenamify';
import { useRecoilCallback } from 'recoil';
import { useEffect, useState } from 'react';
import exportFromJSON from 'export-from-json';
import DialogTemplate from '~/components/ui/DialogTemplate';
import { useGetMessagesByConvoId } from 'librechat-data-provider/react-query';
import { Dialog, DialogButton, Input, Label, Checkbox, Dropdown } from '~/components/ui/';
import { cn, defaultTextProps, removeFocusOutlines, cleanupPreset } from '~/utils/';
import { useScreenshot, useLocalize } from '~/hooks';
import { buildTree } from '~/utils';
import store from '~/store';
export default function ExportModal({ open, onOpenChange, conversation }) {
const { captureScreenshot } = useScreenshot();
const localize = useLocalize();
const [filename, setFileName] = useState('');
const [type, setType] = useState('Select a file type');
const [includeOptions, setIncludeOptions] = useState(true);
const [exportBranches, setExportBranches] = useState(false);
const [recursive, setRecursive] = useState(true);
const { data: messagesTree = null } = useGetMessagesByConvoId(conversation.conversationId ?? '', {
select: (data) => {
const dataTree = buildTree({ messages: data });
return dataTree?.length === 0 ? null : dataTree ?? null;
},
});
const getSiblingIdx = useRecoilCallback(
({ snapshot }) =>
async (messageId) =>
await snapshot.getPromise(store.messagesSiblingIdxFamily(messageId)),
[],
);
const typeOptions = [
{ value: 'screenshot', display: 'screenshot (.png)' },
{ value: 'text', display: 'text (.txt)' },
{ value: 'markdown', display: 'markdown (.md)' },
{ value: 'json', display: 'json (.json)' },
{ value: 'csv', display: 'csv (.csv)' },
]; //,, 'webpage'];
useEffect(() => {
setFileName(filenamify(String(conversation?.title || 'file')));
setType('screenshot');
setIncludeOptions(true);
setExportBranches(false);
setRecursive(true);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open]);
const _setType = (newType) => {
const exportBranchesSupport = newType === 'json' || newType === 'csv' || newType === 'webpage';
const exportOptionsSupport = newType !== 'csv' && newType !== 'screenshot';
setExportBranches(exportBranchesSupport);
setIncludeOptions(exportOptionsSupport);
setType(newType);
};
const exportBranchesSupport = type === 'json' || type === 'csv' || type === 'webpage';
const exportOptionsSupport = type !== 'csv' && type !== 'screenshot';
// return an object or an array based on branches and recursive option
// messageId is used to get siblindIdx from recoil snapshot
const buildMessageTree = async ({
messageId,
message,
messages,
branches = false,
recursive = false,
}) => {
let children = [];
if (messages?.length) {
if (branches) {
for (const message of messages) {
children.push(
await buildMessageTree({
messageId: message?.messageId,
message: message,
messages: message?.children,
branches,
recursive,
}),
);
}
} else {
let message = messages[0];
if (messages?.length > 1) {
const siblingIdx = await getSiblingIdx(messageId);
message = messages[messages.length - siblingIdx - 1];
}
children = [
await buildMessageTree({
messageId: message?.messageId,
message: message,
messages: message?.children,
branches,
recursive,
}),
];
}
}
if (recursive) {
return { ...message, children: children };
} else {
let ret = [];
if (message) {
let _message = { ...message };
delete _message.children;
ret = [_message];
}
for (const child of children) {
ret = ret.concat(child);
}
return ret;
}
};
const exportScreenshot = async () => {
let data;
try {
data = await captureScreenshot();
} catch (err) {
console.error('Failed to capture screenshot');
return console.error(err);
}
download(data, `${filename}.png`, 'image/png');
};
const exportCSV = async () => {
let data = [];
const messages = await buildMessageTree({
messageId: conversation?.conversationId,
message: null,
messages: messagesTree,
branches: exportBranches,
recursive: false,
});
for (const message of messages) {
data.push(message);
}
exportFromJSON({
data: data,
fileName: filename,
extension: 'csv',
exportType: exportFromJSON.types.csv,
beforeTableEncode: (entries) => [
{
fieldName: 'sender',
fieldValues: entries.find((e) => e.fieldName == 'sender').fieldValues,
},
{ fieldName: 'text', fieldValues: entries.find((e) => e.fieldName == 'text').fieldValues },
{
fieldName: 'isCreatedByUser',
fieldValues: entries.find((e) => e.fieldName == 'isCreatedByUser').fieldValues,
},
{
fieldName: 'error',
fieldValues: entries.find((e) => e.fieldName == 'error').fieldValues,
},
{
fieldName: 'unfinished',
fieldValues: entries.find((e) => e.fieldName == 'unfinished').fieldValues,
},
{
fieldName: 'messageId',
fieldValues: entries.find((e) => e.fieldName == 'messageId').fieldValues,
},
{
fieldName: 'parentMessageId',
fieldValues: entries.find((e) => e.fieldName == 'parentMessageId').fieldValues,
},
{
fieldName: 'createdAt',
fieldValues: entries.find((e) => e.fieldName == 'createdAt').fieldValues,
},
],
});
};
const exportMarkdown = async () => {
let data =
'# Conversation\n' +
`- conversationId: ${conversation?.conversationId}\n` +
`- endpoint: ${conversation?.endpoint}\n` +
`- title: ${conversation?.title}\n` +
`- exportAt: ${new Date().toTimeString()}\n`;
if (includeOptions) {
data += '\n## Options\n';
const options = cleanupPreset({ preset: conversation });
for (const key of Object.keys(options)) {
data += `- ${key}: ${options[key]}\n`;
}
}
const messages = await buildMessageTree({
messageId: conversation?.conversationId,
message: null,
messages: messagesTree,
branches: false,
recursive: false,
});
data += '\n## History\n';
for (const message of messages) {
data += `**${message?.sender}:**\n${message?.text}\n`;
if (message.error) {
data += '*(This is an error message)*\n';
}
if (message.unfinished) {
data += '*(This is an unfinished message)*\n';
}
data += '\n\n';
}
exportFromJSON({
data: data,
fileName: filename,
extension: 'md',
exportType: exportFromJSON.types.text,
});
};
const exportText = async () => {
let data =
'Conversation\n' +
'########################\n' +
`conversationId: ${conversation?.conversationId}\n` +
`endpoint: ${conversation?.endpoint}\n` +
`title: ${conversation?.title}\n` +
`exportAt: ${new Date().toTimeString()}\n`;
if (includeOptions) {
data += '\nOptions\n########################\n';
const options = cleanupPreset({ preset: conversation });
for (const key of Object.keys(options)) {
data += `${key}: ${options[key]}\n`;
}
}
const messages = await buildMessageTree({
messageId: conversation?.conversationId,
message: null,
messages: messagesTree,
branches: false,
recursive: false,
});
data += '\nHistory\n########################\n';
for (const message of messages) {
data += `>> ${message?.sender}:\n${message?.text}\n`;
if (message.error) {
data += '(This is an error message)\n';
}
if (message.unfinished) {
data += '(This is an unfinished message)\n';
}
data += '\n\n';
}
exportFromJSON({
data: data,
fileName: filename,
extension: 'txt',
exportType: exportFromJSON.types.text,
});
};
const exportJSON = async () => {
let data = {
conversationId: conversation?.conversationId,
endpoint: conversation?.endpoint,
title: conversation?.title,
exportAt: new Date().toTimeString(),
branches: exportBranches,
recursive: recursive,
};
if (includeOptions) {
data.options = cleanupPreset({ preset: conversation });
}
const messages = await buildMessageTree({
messageId: conversation?.conversationId,
message: null,
messages: messagesTree,
branches: exportBranches,
recursive: recursive,
});
if (recursive) {
data.messagesTree = messages.children;
} else {
data.messages = messages;
}
exportFromJSON({
data: data,
fileName: filename,
extension: 'json',
exportType: exportFromJSON.types.json,
});
};
const exportConversation = () => {
if (type === 'json') {
exportJSON();
} else if (type == 'text') {
exportText();
} else if (type == 'markdown') {
exportMarkdown();
} else if (type == 'csv') {
exportCSV();
} else if (type == 'screenshot') {
exportScreenshot();
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogTemplate
title={localize('com_nav_export_conversation')}
className="max-w-full sm:max-w-2xl"
main={
<div className="flex w-full flex-col items-center gap-6">
<div className="grid w-full gap-6 sm:grid-cols-2">
<div className="col-span-1 flex flex-col items-start justify-start gap-2">
<Label htmlFor="filename" className="text-left text-sm font-medium">
{localize('com_nav_export_filename')}
</Label>
<Input
id="filename"
value={filename}
onChange={(e) => setFileName(filenamify(e.target.value || ''))}
placeholder={localize('com_nav_export_filename_placeholder')}
className={cn(
defaultTextProps,
'flex h-10 max-h-10 w-full resize-none px-3 py-2',
removeFocusOutlines,
)}
/>
</div>
<div className="col-span-1 flex w-full flex-col items-start justify-start gap-2">
<Label htmlFor="type" className="text-left text-sm font-medium">
{localize('com_nav_export_type')}
</Label>
<Dropdown id="type" value={type} onChange={_setType} options={typeOptions} />
</div>
</div>
<div className="grid w-full gap-6 sm:grid-cols-2">
<div className="col-span-1 flex flex-col items-start justify-start gap-2">
<div className="grid w-full items-center gap-2">
<Label htmlFor="includeOptions" className="text-left text-sm font-medium">
{localize('com_nav_export_include_endpoint_options')}
</Label>
<div className="flex h-[40px] w-full items-center space-x-3">
<Checkbox
id="includeOptions"
disabled={!exportOptionsSupport}
checked={includeOptions}
className="focus:ring-opacity-20 dark:border-gray-500 dark:bg-gray-700 dark:text-gray-50 dark:focus:ring-gray-600 dark:focus:ring-opacity-50 dark:focus:ring-offset-0"
onCheckedChange={setIncludeOptions}
/>
<label
htmlFor="includeOptions"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 dark:text-gray-50"
>
{exportOptionsSupport
? localize('com_nav_enabled')
: localize('com_nav_not_supported')}
</label>
</div>
</div>
</div>
<div className="grid w-full items-center gap-2">
<Label htmlFor="exportBranches" className="text-left text-sm font-medium">
{localize('com_nav_export_all_message_branches')}
</Label>
<div className="flex h-[40px] w-full items-center space-x-3">
<Checkbox
id="exportBranches"
disabled={!exportBranchesSupport}
checked={exportBranches}
className="focus:ring-opacity-20 dark:border-gray-500 dark:bg-gray-700 dark:text-gray-50 dark:focus:ring-gray-600 dark:focus:ring-opacity-50 dark:focus:ring-offset-0"
onCheckedChange={setExportBranches}
/>
<label
htmlFor="exportBranches"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 dark:text-gray-50"
>
{exportBranchesSupport
? localize('com_nav_enabled')
: localize('com_nav_not_supported')}
</label>
</div>
</div>
{type === 'json' ? (
<div className="grid w-full items-center gap-2">
<Label htmlFor="recursive" className="text-left text-sm font-medium">
{localize('com_nav_export_recursive_or_sequential')}
</Label>
<div className="flex h-[40px] w-full items-center space-x-3">
<Checkbox
id="recursive"
checked={recursive}
className="focus:ring-opacity-20 dark:border-gray-500 dark:bg-gray-700 dark:text-gray-50 dark:focus:ring-gray-600 dark:focus:ring-opacity-50 dark:focus:ring-offset-0"
onCheckedChange={setRecursive}
/>
<label
htmlFor="recursive"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 dark:text-gray-50"
>
{localize('com_nav_export_recursive')}
</label>
</div>
</div>
) : null}
</div>
</div>
}
buttons={
<>
<DialogButton
onClick={exportConversation}
className="dark:hover:gray-400 border-gray-700 bg-green-500 text-white hover:bg-green-600 dark:hover:bg-green-600"
>
{localize('com_endpoint_export')}
</DialogButton>
</>
}
selection={null}
/>
</Dialog>
);
}

View file

@ -0,0 +1,179 @@
import filenamify from 'filenamify';
import { useEffect, useState } from 'react';
import type { TConversation } from 'librechat-data-provider';
import { Dialog, DialogButton, Input, Label, Checkbox, Dropdown } from '~/components/ui';
import { useLocalize, useExportConversation } from '~/hooks';
import DialogTemplate from '~/components/ui/DialogTemplate';
import { cn, defaultTextProps } from '~/utils';
export default function ExportModal({
open,
onOpenChange,
conversation,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
conversation: TConversation | null;
}) {
const localize = useLocalize();
const [filename, setFileName] = useState('');
const [type, setType] = useState('Select a file type');
const [includeOptions, setIncludeOptions] = useState<boolean | 'indeterminate'>(true);
const [exportBranches, setExportBranches] = useState<boolean | 'indeterminate'>(false);
const [recursive, setRecursive] = useState<boolean | 'indeterminate'>(true);
const typeOptions = [
{ value: 'screenshot', display: 'screenshot (.png)' },
{ value: 'text', display: 'text (.txt)' },
{ value: 'markdown', display: 'markdown (.md)' },
{ value: 'json', display: 'json (.json)' },
{ value: 'csv', display: 'csv (.csv)' },
];
useEffect(() => {
setFileName(filenamify(String(conversation?.title || 'file')));
setType('screenshot');
setIncludeOptions(true);
setExportBranches(false);
setRecursive(true);
}, [conversation?.title, open]);
const _setType = (newType: string) => {
const exportBranchesSupport = newType === 'json' || newType === 'csv' || newType === 'webpage';
const exportOptionsSupport = newType !== 'csv' && newType !== 'screenshot';
setExportBranches(exportBranchesSupport);
setIncludeOptions(exportOptionsSupport);
setType(newType);
};
const exportBranchesSupport = type === 'json' || type === 'csv' || type === 'webpage';
const exportOptionsSupport = type !== 'csv' && type !== 'screenshot';
const { exportConversation } = useExportConversation({
conversation,
filename,
type,
includeOptions,
exportBranches,
recursive,
});
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogTemplate
title={localize('com_nav_export_conversation')}
className="max-w-full sm:max-w-2xl"
main={
<div className="flex w-full flex-col items-center gap-6">
<div className="grid w-full gap-6 sm:grid-cols-2">
<div className="col-span-1 flex flex-col items-start justify-start gap-2">
<Label htmlFor="filename" className="text-left text-sm font-medium">
{localize('com_nav_export_filename')}
</Label>
<Input
id="filename"
value={filename}
onChange={(e) => setFileName(filenamify(e.target.value || ''))}
placeholder={localize('com_nav_export_filename_placeholder')}
className={cn(
defaultTextProps,
'flex h-10 max-h-10 w-full resize-none px-3 py-2',
)}
/>
</div>
<div className="col-span-1 flex w-full flex-col items-start justify-start gap-2">
<Label htmlFor="type" className="text-left text-sm font-medium">
{localize('com_nav_export_type')}
</Label>
<Dropdown value={type} onChange={_setType} options={typeOptions} />
</div>
</div>
<div className="grid w-full gap-6 sm:grid-cols-2">
<div className="col-span-1 flex flex-col items-start justify-start gap-2">
<div className="grid w-full items-center gap-2">
<Label htmlFor="includeOptions" className="text-left text-sm font-medium">
{localize('com_nav_export_include_endpoint_options')}
</Label>
<div className="flex h-[40px] w-full items-center space-x-3">
<Checkbox
id="includeOptions"
disabled={!exportOptionsSupport}
checked={includeOptions}
className="focus:ring-opacity-20 dark:border-gray-500 dark:bg-gray-700 dark:text-gray-50 dark:focus:ring-gray-600 dark:focus:ring-opacity-50 dark:focus:ring-offset-0"
onCheckedChange={setIncludeOptions}
/>
<label
htmlFor="includeOptions"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 dark:text-gray-50"
>
{exportOptionsSupport
? localize('com_nav_enabled')
: localize('com_nav_not_supported')}
</label>
</div>
</div>
</div>
<div className="grid w-full items-center gap-2">
<Label htmlFor="exportBranches" className="text-left text-sm font-medium">
{localize('com_nav_export_all_message_branches')}
</Label>
<div className="flex h-[40px] w-full items-center space-x-3">
<Checkbox
id="exportBranches"
disabled={!exportBranchesSupport}
checked={exportBranches}
className="focus:ring-opacity-20 dark:border-gray-500 dark:bg-gray-700 dark:text-gray-50 dark:focus:ring-gray-600 dark:focus:ring-opacity-50 dark:focus:ring-offset-0"
onCheckedChange={setExportBranches}
/>
<label
htmlFor="exportBranches"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 dark:text-gray-50"
>
{exportBranchesSupport
? localize('com_nav_enabled')
: localize('com_nav_not_supported')}
</label>
</div>
</div>
{type === 'json' ? (
<div className="grid w-full items-center gap-2">
<Label htmlFor="recursive" className="text-left text-sm font-medium">
{localize('com_nav_export_recursive_or_sequential')}
</Label>
<div className="flex h-[40px] w-full items-center space-x-3">
<Checkbox
id="recursive"
checked={recursive}
className="focus:ring-opacity-20 dark:border-gray-500 dark:bg-gray-700 dark:text-gray-50 dark:focus:ring-gray-600 dark:focus:ring-opacity-50 dark:focus:ring-offset-0"
onCheckedChange={setRecursive}
/>
<label
htmlFor="recursive"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 dark:text-gray-50"
>
{localize('com_nav_export_recursive')}
</label>
</div>
</div>
) : null}
</div>
</div>
}
buttons={
<>
<DialogButton
onClick={exportConversation}
className="dark:hover:gray-400 border-gray-700 bg-green-500 text-white hover:bg-green-600 dark:hover:bg-green-600"
>
{localize('com_endpoint_export')}
</DialogButton>
</>
}
selection={undefined}
/>
</Dialog>
);
}

View file

@ -1,2 +1 @@
export { default as ExportConversation } from './ExportConversation';
export { default as ExportModal } from './ExportModal'; export { default as ExportModal } from './ExportModal';

View file

@ -8,3 +8,4 @@ export { default as useDebouncedInput } from './useDebouncedInput';
export { default as useNavigateToConvo } from './useNavigateToConvo'; export { default as useNavigateToConvo } from './useNavigateToConvo';
export { default as useSetIndexOptions } from './useSetIndexOptions'; export { default as useSetIndexOptions } from './useSetIndexOptions';
export { default as useParameterEffects } from './useParameterEffects'; export { default as useParameterEffects } from './useParameterEffects';
export { default as useExportConversation } from './useExportConversation';

View file

@ -0,0 +1,369 @@
import download from 'downloadjs';
import exportFromJSON from 'export-from-json';
import { useGetMessagesByConvoId } from 'librechat-data-provider/react-query';
import {
ContentTypes,
ToolCallTypes,
imageGenTools,
isImageVisionTool,
} from 'librechat-data-provider';
import type {
TMessage,
TPreset,
TConversation,
TMessageContentParts,
} from 'librechat-data-provider';
import useBuildMessageTree from '~/hooks/Messages/useBuildMessageTree';
import { useScreenshot } from '~/hooks/ScreenshotContext';
import { cleanupPreset, buildTree } from '~/utils';
export default function useExportConversation({
conversation,
filename,
type,
includeOptions,
exportBranches,
recursive,
}: {
conversation: TConversation | null;
filename: string;
type: string;
includeOptions: boolean | 'indeterminate';
exportBranches: boolean | 'indeterminate';
recursive: boolean | 'indeterminate';
}) {
const { captureScreenshot } = useScreenshot();
const buildMessageTree = useBuildMessageTree();
const { data: messagesTree = null } = useGetMessagesByConvoId(
conversation?.conversationId ?? '',
{
select: (data) => {
const dataTree = buildTree({ messages: data });
return dataTree?.length === 0 ? null : dataTree ?? null;
},
},
);
const getMessageText = (message: TMessage, format = 'text') => {
if (!message) {
return '';
}
const formatText = (sender, text) => {
if (format === 'text') {
return `>> ${sender}:\n${text}`;
}
return `**${sender}**\n${text}`;
};
if (!message.content) {
return formatText(message.sender, message.text);
}
return message.content
.map((content) => getMessageContent(message.sender, content))
.map((text) => {
return formatText(text[0], text[1]);
})
.join('\n\n\n');
};
/**
* Format and return message texts according to the type of content.
* Currently, content whose type is `TOOL_CALL` basically returns JSON as is.
* In the future, different formatted text may be returned for each type.
*/
const getMessageContent = (sender: string, content: TMessageContentParts): string[] => {
if (!content) {
return [];
}
if (content.type === ContentTypes.ERROR) {
// ERROR
return [sender, content[ContentTypes.TEXT].value];
}
if (content.type === ContentTypes.TEXT) {
// TEXT
return [sender, content[ContentTypes.TEXT].value];
}
if (content.type === ContentTypes.TOOL_CALL) {
const type = content[ContentTypes.TOOL_CALL].type;
if (type === ToolCallTypes.CODE_INTERPRETER) {
// CODE_INTERPRETER
const toolCall = content[ContentTypes.TOOL_CALL];
const code_interpreter = toolCall[ToolCallTypes.CODE_INTERPRETER];
return ['Code Interpreter', JSON.stringify(code_interpreter)];
}
if (type === ToolCallTypes.RETRIEVAL) {
// RETRIEVAL
const toolCall = content[ContentTypes.TOOL_CALL];
return ['Retrieval', JSON.stringify(toolCall)];
}
if (
type === ToolCallTypes.FUNCTION &&
imageGenTools.has(content[ContentTypes.TOOL_CALL].function.name)
) {
// IMAGE_GENERATION
const toolCall = content[ContentTypes.TOOL_CALL];
return ['Tool', JSON.stringify(toolCall)];
}
if (type === ToolCallTypes.FUNCTION) {
// IMAGE_VISION
const toolCall = content[ContentTypes.TOOL_CALL];
if (isImageVisionTool(toolCall)) {
return ['Tool', JSON.stringify(toolCall)];
}
return ['Tool', JSON.stringify(toolCall)];
}
}
if (content.type === ContentTypes.IMAGE_FILE) {
// IMAGE
const imageFile = content[ContentTypes.IMAGE_FILE];
return ['Image', JSON.stringify(imageFile)];
}
return [sender, JSON.stringify(content)];
};
const exportScreenshot = async () => {
let data;
try {
data = await captureScreenshot();
} catch (err) {
console.error('Failed to capture screenshot');
return console.error(err);
}
download(data, `${filename}.png`, 'image/png');
};
const exportCSV = async () => {
const data: TMessage[] = [];
const messages = await buildMessageTree({
messageId: conversation?.conversationId,
message: null,
messages: messagesTree,
branches: !!exportBranches,
recursive: false,
});
if (Array.isArray(messages)) {
for (const message of messages) {
data.push(message);
}
} else {
data.push(messages);
}
exportFromJSON({
data: data,
fileName: filename,
extension: 'csv',
exportType: exportFromJSON.types.csv,
beforeTableEncode: (entries) => [
{
fieldName: 'sender',
fieldValues: entries?.find((e) => e.fieldName == 'sender')?.fieldValues ?? [],
},
{
fieldName: 'text',
fieldValues: entries?.find((e) => e.fieldName == 'text')?.fieldValues ?? [],
},
{
fieldName: 'isCreatedByUser',
fieldValues: entries?.find((e) => e.fieldName == 'isCreatedByUser')?.fieldValues ?? [],
},
{
fieldName: 'error',
fieldValues: entries?.find((e) => e.fieldName == 'error')?.fieldValues ?? [],
},
{
fieldName: 'unfinished',
fieldValues: entries?.find((e) => e.fieldName == 'unfinished')?.fieldValues ?? [],
},
{
fieldName: 'messageId',
fieldValues: entries?.find((e) => e.fieldName == 'messageId')?.fieldValues ?? [],
},
{
fieldName: 'parentMessageId',
fieldValues: entries?.find((e) => e.fieldName == 'parentMessageId')?.fieldValues ?? [],
},
{
fieldName: 'createdAt',
fieldValues: entries?.find((e) => e.fieldName == 'createdAt')?.fieldValues ?? [],
},
],
});
};
const exportMarkdown = async () => {
let data =
'# Conversation\n' +
`- conversationId: ${conversation?.conversationId}\n` +
`- endpoint: ${conversation?.endpoint}\n` +
`- title: ${conversation?.title}\n` +
`- exportAt: ${new Date().toTimeString()}\n`;
if (includeOptions) {
data += '\n## Options\n';
const options = cleanupPreset({ preset: conversation as TPreset });
for (const key of Object.keys(options)) {
data += `- ${key}: ${options[key]}\n`;
}
}
const messages = await buildMessageTree({
messageId: conversation?.conversationId,
message: null,
messages: messagesTree,
branches: false,
recursive: false,
});
data += '\n## History\n';
if (Array.isArray(messages)) {
for (const message of messages) {
data += `${getMessageText(message, 'md')}\n`;
if (message.error) {
data += '*(This is an error message)*\n';
}
if (message.unfinished) {
data += '*(This is an unfinished message)*\n';
}
data += '\n\n';
}
} else {
data += `${getMessageText(messages, 'md')}\n`;
if (messages.error) {
data += '*(This is an error message)*\n';
}
if (messages.unfinished) {
data += '*(This is an unfinished message)*\n';
}
}
exportFromJSON({
data: data,
fileName: filename,
extension: 'md',
exportType: exportFromJSON.types.txt,
});
};
const exportText = async () => {
let data =
'Conversation\n' +
'########################\n' +
`conversationId: ${conversation?.conversationId}\n` +
`endpoint: ${conversation?.endpoint}\n` +
`title: ${conversation?.title}\n` +
`exportAt: ${new Date().toTimeString()}\n`;
if (includeOptions) {
data += '\nOptions\n########################\n';
const options = cleanupPreset({ preset: conversation as TPreset });
for (const key of Object.keys(options)) {
data += `${key}: ${options[key]}\n`;
}
}
const messages = await buildMessageTree({
messageId: conversation?.conversationId,
message: null,
messages: messagesTree,
branches: false,
recursive: false,
});
data += '\nHistory\n########################\n';
if (Array.isArray(messages)) {
for (const message of messages) {
data += `${getMessageText(message)}\n`;
if (message.error) {
data += '(This is an error message)\n';
}
if (message.unfinished) {
data += '(This is an unfinished message)\n';
}
data += '\n\n';
}
} else {
data += `${getMessageText(messages)}\n`;
if (messages.error) {
data += '(This is an error message)\n';
}
if (messages.unfinished) {
data += '(This is an unfinished message)\n';
}
}
exportFromJSON({
data: data,
fileName: filename,
extension: 'txt',
exportType: exportFromJSON.types.txt,
});
};
const exportJSON = async () => {
const data = {
conversationId: conversation?.conversationId,
endpoint: conversation?.endpoint,
title: conversation?.title,
exportAt: new Date().toTimeString(),
branches: exportBranches,
recursive: recursive,
};
if (includeOptions) {
data['options'] = cleanupPreset({ preset: conversation as TPreset });
}
const messages = await buildMessageTree({
messageId: conversation?.conversationId,
message: null,
messages: messagesTree,
branches: !!exportBranches,
recursive: !!recursive,
});
if (recursive && !Array.isArray(messages)) {
data['messagesTree'] = messages.children;
} else {
data['messages'] = messages;
}
exportFromJSON({
data: data,
fileName: filename,
extension: 'json',
exportType: exportFromJSON.types.json,
});
};
const exportConversation = () => {
if (type === 'json') {
exportJSON();
} else if (type == 'text') {
exportText();
} else if (type == 'markdown') {
exportMarkdown();
} else if (type == 'csv') {
exportCSV();
} else if (type == 'screenshot') {
exportScreenshot();
}
};
return { exportConversation };
}

View file

@ -0,0 +1,78 @@
import { useRecoilCallback } from 'recoil';
import type { TMessage } from 'librechat-data-provider';
import store from '~/store';
export default function useBuildMessageTree() {
const getSiblingIdx = useRecoilCallback(
({ snapshot }) =>
async (messageId: string | null | undefined) =>
await snapshot.getPromise(store.messagesSiblingIdxFamily(messageId)),
[],
);
// return an object or an array based on branches and recursive option
// messageId is used to get siblindIdx from recoil snapshot
const buildMessageTree = async ({
messageId,
message,
messages,
branches = false,
recursive = false,
}: {
messageId: string | null | undefined;
message: TMessage | null;
messages: TMessage[] | null;
branches?: boolean;
recursive?: boolean;
}): Promise<TMessage | TMessage[]> => {
let children: TMessage[] = [];
if (messages?.length) {
if (branches) {
for (const message of messages) {
children.push(
(await buildMessageTree({
messageId: message?.messageId,
message: message,
messages: message?.children || [],
branches,
recursive,
})) as TMessage,
);
}
} else {
let message = messages[0];
if (messages?.length > 1) {
const siblingIdx = await getSiblingIdx(messageId);
message = messages[messages.length - siblingIdx - 1];
}
children = [
(await buildMessageTree({
messageId: message?.messageId,
message: message,
messages: message?.children || [],
branches,
recursive,
})) as TMessage,
];
}
}
if (recursive && message) {
return { ...message, children: children };
} else {
let ret: TMessage[] = [];
if (message) {
const _message = { ...message };
delete _message.children;
ret = [_message];
}
for (const child of children) {
ret = ret.concat(child);
}
return ret;
}
};
return buildMessageTree;
}