From 6f0b559927c24bb841c1d5a028fc980b5a2d5773 Mon Sep 17 00:00:00 2001 From: Wentao Lyu <35-wentao.lyu@users.noreply.git.stereye.tech> Date: Thu, 6 Apr 2023 02:06:39 +0800 Subject: [PATCH] feat: export conversation: csv, json, txt, markdown --- client/src/components/Messages/Message.jsx | 1 + .../src/components/Messages/MultiMessage.jsx | 8 +- client/src/components/Messages/index.jsx | 1 + .../Nav/ExportConversation/ExportModel.jsx | 404 ++++++++++++++++++ .../Nav/ExportConversation/index.jsx | 43 ++ client/src/components/Nav/NavLinks.jsx | 2 + client/src/store/conversation.js | 15 +- 7 files changed, 472 insertions(+), 2 deletions(-) create mode 100644 client/src/components/Nav/ExportConversation/ExportModel.jsx create mode 100644 client/src/components/Nav/ExportConversation/index.jsx diff --git a/client/src/components/Messages/Message.jsx b/client/src/components/Messages/Message.jsx index 62d14c62ff..8e250cce88 100644 --- a/client/src/components/Messages/Message.jsx +++ b/client/src/components/Messages/Message.jsx @@ -205,6 +205,7 @@ export default function Message({ { setSiblingIdx(messagesTree?.length - value - 1); diff --git a/client/src/components/Messages/index.jsx b/client/src/components/Messages/index.jsx index a458bf56e3..3a7f95c3d1 100644 --- a/client/src/components/Messages/index.jsx +++ b/client/src/components/Messages/index.jsx @@ -97,6 +97,7 @@ export default function Messages({ isSearchView = false }) { <> + async messageId => + await snapshot.getPromise(store.messagesSiblingIdxFamily(messageId)), + [] + ); + + const typeOptions = ['text', 'markdown', 'csv', 'json']; //, 'screenshot', 'webpage']; + + useEffect(() => { + setFileName( + String(conversation?.title) + .replace(/[^a-z0-9]/gi, '_') + .toLowerCase() || 'file' + ); + setType('text'); + setIncludeOptions(true); + setExportBranches(false); + setExportBranchesSupport(false); + setRecursive(true); + }, [open]); + + const _setType = newType => { + if (newType === 'json' || newType === 'csv' || newType === 'webpage') { + setExportBranches(true); + setExportBranchesSupport(true); + } else { + setExportBranches(false); + setExportBranchesSupport(false); + } + setType(newType); + }; + + // 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 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: '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, endpointsFilter }); + + 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\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, endpointsFilter }); + + 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\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, endpointsFilter }); + + 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(); + }; + + const defaultTextProps = + 'rounded-md border border-gray-200 focus:border-slate-400 focus:bg-gray-50 bg-transparent text-sm shadow-[0_0_10px_rgba(0,0,0,0.05)] outline-none placeholder:text-gray-400 focus:outline-none focus:ring-gray-400 focus:ring-opacity-20 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-500 dark:bg-gray-700 focus:dark:bg-gray-600 dark:text-gray-50 dark:shadow-[0_0_15px_rgba(0,0,0,0.10)] dark:focus:border-gray-400 dark:focus:outline-none dark:focus:ring-0 dark:focus:ring-gray-400 dark:focus:ring-offset-0'; + + return ( + + +
+
+ + setFileName(e.target.value || '')} + placeholder="Set the filename" + className={cn( + defaultTextProps, + 'flex h-10 max-h-10 w-full resize-none px-3 py-2 focus:outline-none focus:ring-0 focus:ring-opacity-0 focus:ring-offset-0' + )} + /> +
+
+ + +
+
+
+ {type !== 'csv' ? ( +
+
+ +
+ + +
+
+
+ ) : null} +
+ +
+ + +
+
+ {type === 'json' ? ( +
+ +
+ + +
+
+ ) : null} +
+ + } + buttons={ + <> + + Export + + + } + selection={null} + /> +
+ ); +} diff --git a/client/src/components/Nav/ExportConversation/index.jsx b/client/src/components/Nav/ExportConversation/index.jsx new file mode 100644 index 0000000000..7548c48374 --- /dev/null +++ b/client/src/components/Nav/ExportConversation/index.jsx @@ -0,0 +1,43 @@ +import React, { useState } from 'react'; +import { useRecoilValue } from 'recoil'; +import { Download } from 'lucide-react'; +import { cn } from '~/utils/'; + +import ExportModel from './ExportModel'; + +import store from '~/store'; + +export default function ExportConversation() { + const [open, setOpen] = useState(false); + + const conversation = useRecoilValue(store.conversation) || {}; + + const exportable = + conversation?.conversationId && + conversation?.conversationId !== 'new' && + conversation?.conversationId !== 'search'; + + const clickHandler = () => { + if (exportable) setOpen(true); + }; + + return ( + <> + + + Export conversation + + + + + ); +} diff --git a/client/src/components/Nav/NavLinks.jsx b/client/src/components/Nav/NavLinks.jsx index 9626845d46..33f0e453c7 100644 --- a/client/src/components/Nav/NavLinks.jsx +++ b/client/src/components/Nav/NavLinks.jsx @@ -3,6 +3,7 @@ import SearchBar from './SearchBar'; import ClearConvos from './ClearConvos'; import DarkMode from './DarkMode'; import Logout from './Logout'; +import ExportConversation from './ExportConversation'; export default function NavLinks({ fetch, onSearchSuccess, clearSearch, isSearchEnabled }) { return ( @@ -14,6 +15,7 @@ export default function NavLinks({ fetch, onSearchSuccess, clearSearch, isSearch clearSearch={clearSearch} /> )} + diff --git a/client/src/store/conversation.js b/client/src/store/conversation.js index 79433f7d5a..c13cbc48ec 100644 --- a/client/src/store/conversation.js +++ b/client/src/store/conversation.js @@ -1,5 +1,12 @@ import endpoints from './endpoints'; -import { atom, selector, useSetRecoilState, useResetRecoilState, useRecoilCallback } from 'recoil'; +import { + atom, + selector, + atomFamily, + useSetRecoilState, + useResetRecoilState, + useRecoilCallback +} from 'recoil'; import buildTree from '~/utils/buildTree'; import getDefaultConversation from '~/utils/getDefaultConversation'; import submission from './submission.js'; @@ -57,6 +64,11 @@ const latestMessage = atom({ default: null }); +const messagesSiblingIdxFamily = atomFamily({ + key: 'messagesSiblingIdx', + default: 0 +}); + const useConversation = () => { const setConversation = useSetRecoilState(conversation); const setMessages = useSetRecoilState(messages); @@ -129,5 +141,6 @@ export default { messages, messagesTree, latestMessage, + messagesSiblingIdxFamily, useConversation };