From 9f9d931276bed18b0d6bac2481f0e8e25d9bac24 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Fri, 17 May 2024 14:10:40 -0400 Subject: [PATCH] =?UTF-8?q?=F0=9F=9A=91=20fix(export):=20Issue=20exporting?= =?UTF-8?q?=20Conversation=20with=20Assistants=20(#2769)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 🚑 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 --- .../ExportConversation/ExportConversation.jsx | 44 -- .../Nav/ExportConversation/ExportModal.jsx | 448 ------------------ .../Nav/ExportConversation/ExportModal.tsx | 179 +++++++ .../Nav/ExportConversation/index.ts | 1 - client/src/hooks/Conversations/index.ts | 1 + .../Conversations/useExportConversation.ts | 369 +++++++++++++++ .../src/hooks/Messages/useBuildMessageTree.ts | 78 +++ 7 files changed, 627 insertions(+), 493 deletions(-) delete mode 100644 client/src/components/Nav/ExportConversation/ExportConversation.jsx delete mode 100644 client/src/components/Nav/ExportConversation/ExportModal.jsx create mode 100644 client/src/components/Nav/ExportConversation/ExportModal.tsx create mode 100644 client/src/hooks/Conversations/useExportConversation.ts create mode 100644 client/src/hooks/Messages/useBuildMessageTree.ts diff --git a/client/src/components/Nav/ExportConversation/ExportConversation.jsx b/client/src/components/Nav/ExportConversation/ExportConversation.jsx deleted file mode 100644 index edd935ecbc1..00000000000 --- a/client/src/components/Nav/ExportConversation/ExportConversation.jsx +++ /dev/null @@ -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 ( - <> - - - - - ); -}); - -export default ExportConversation; diff --git a/client/src/components/Nav/ExportConversation/ExportModal.jsx b/client/src/components/Nav/ExportConversation/ExportModal.jsx deleted file mode 100644 index d7dd847f943..00000000000 --- a/client/src/components/Nav/ExportConversation/ExportModal.jsx +++ /dev/null @@ -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 ( - - -
-
- - 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, - )} - /> -
-
- - -
-
-
-
-
- -
- - -
-
-
-
- -
- - -
-
- {type === 'json' ? ( -
- -
- - -
-
- ) : null} -
- - } - buttons={ - <> - - {localize('com_endpoint_export')} - - - } - selection={null} - /> -
- ); -} diff --git a/client/src/components/Nav/ExportConversation/ExportModal.tsx b/client/src/components/Nav/ExportConversation/ExportModal.tsx new file mode 100644 index 00000000000..f63a39c3cab --- /dev/null +++ b/client/src/components/Nav/ExportConversation/ExportModal.tsx @@ -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(true); + const [exportBranches, setExportBranches] = useState(false); + const [recursive, setRecursive] = useState(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 ( + + +
+
+ + 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', + )} + /> +
+
+ + +
+
+
+
+
+ +
+ + +
+
+
+
+ +
+ + +
+
+ {type === 'json' ? ( +
+ +
+ + +
+
+ ) : null} +
+ + } + buttons={ + <> + + {localize('com_endpoint_export')} + + + } + selection={undefined} + /> +
+ ); +} diff --git a/client/src/components/Nav/ExportConversation/index.ts b/client/src/components/Nav/ExportConversation/index.ts index a4321d9e945..b5a5a2c89ce 100644 --- a/client/src/components/Nav/ExportConversation/index.ts +++ b/client/src/components/Nav/ExportConversation/index.ts @@ -1,2 +1 @@ -export { default as ExportConversation } from './ExportConversation'; export { default as ExportModal } from './ExportModal'; diff --git a/client/src/hooks/Conversations/index.ts b/client/src/hooks/Conversations/index.ts index be63e73a646..5e5cabb81c0 100644 --- a/client/src/hooks/Conversations/index.ts +++ b/client/src/hooks/Conversations/index.ts @@ -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'; diff --git a/client/src/hooks/Conversations/useExportConversation.ts b/client/src/hooks/Conversations/useExportConversation.ts new file mode 100644 index 00000000000..5c9750e4dac --- /dev/null +++ b/client/src/hooks/Conversations/useExportConversation.ts @@ -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 }; +} diff --git a/client/src/hooks/Messages/useBuildMessageTree.ts b/client/src/hooks/Messages/useBuildMessageTree.ts new file mode 100644 index 00000000000..d1e40318c10 --- /dev/null +++ b/client/src/hooks/Messages/useBuildMessageTree.ts @@ -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 => { + 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; +}