mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-12-18 01:10:14 +01:00
🚑 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:
parent
31479d6a48
commit
53fe2f6453
7 changed files with 627 additions and 493 deletions
|
|
@ -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;
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
179
client/src/components/Nav/ExportConversation/ExportModal.tsx
Normal file
179
client/src/components/Nav/ExportConversation/ExportModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,2 +1 @@
|
|||
export { default as ExportConversation } from './ExportConversation';
|
||||
export { default as ExportModal } from './ExportModal';
|
||||
|
|
|
|||
|
|
@ -8,3 +8,4 @@ export { default as useDebouncedInput } from './useDebouncedInput';
|
|||
export { default as useNavigateToConvo } from './useNavigateToConvo';
|
||||
export { default as useSetIndexOptions } from './useSetIndexOptions';
|
||||
export { default as useParameterEffects } from './useParameterEffects';
|
||||
export { default as useExportConversation } from './useExportConversation';
|
||||
|
|
|
|||
369
client/src/hooks/Conversations/useExportConversation.ts
Normal file
369
client/src/hooks/Conversations/useExportConversation.ts
Normal 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 };
|
||||
}
|
||||
78
client/src/hooks/Messages/useBuildMessageTree.ts
Normal file
78
client/src/hooks/Messages/useBuildMessageTree.ts
Normal 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;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue