feat: import openai chatgpt export

This commit is contained in:
Jing Hua 2023-04-21 17:20:38 +08:00
parent b3f421cde9
commit ece4778f88
6 changed files with 357 additions and 195 deletions

View file

@ -0,0 +1,35 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import useStore from '@store/store';
import downloadFile from '@utils/downloadFile';
import { getToday } from '@utils/date';
import Export from '@type/export';
const ExportChat = () => {
const { t } = useTranslation();
return (
<div className='mt-6'>
<div className='block mb-2 text-sm font-medium text-gray-900 dark:text-gray-300'>
{t('export')} (JSON)
</div>
<button
className='btn btn-small btn-primary'
onClick={() => {
const fileData: Export = {
chats: useStore.getState().chats,
folders: useStore.getState().folders,
version: 1,
};
downloadFile(fileData, getToday());
}}
>
{t('export')}
</button>
</div>
);
};
export default ExportChat;

View file

@ -0,0 +1,173 @@
import React, { useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { v4 as uuidv4 } from 'uuid';
import useStore from '@store/store';
import {
isLegacyImport,
validateAndFixChats,
validateExportV1,
} from '@utils/import';
import { ChatInterface, Folder, FolderCollection } from '@type/chat';
import { ExportBase } from '@type/export';
const ImportChat = () => {
const { t } = useTranslation();
const setChats = useStore.getState().setChats;
const setFolders = useStore.getState().setFolders;
const inputRef = useRef<HTMLInputElement>(null);
const [alert, setAlert] = useState<{
message: string;
success: boolean;
} | null>(null);
const handleFileUpload = () => {
if (!inputRef || !inputRef.current) return;
const file = inputRef.current.files?.[0];
if (file) {
const reader = new FileReader();
reader.onload = (event) => {
const data = event.target?.result as string;
try {
const parsedData = JSON.parse(data);
if (isLegacyImport(parsedData)) {
if (validateAndFixChats(parsedData)) {
// import new folders
const folderNameToIdMap: Record<string, string> = {};
const parsedFolders: string[] = [];
parsedData.forEach((data) => {
const folder = data.folder;
if (folder) {
if (!parsedFolders.includes(folder)) {
parsedFolders.push(folder);
folderNameToIdMap[folder] = uuidv4();
}
data.folder = folderNameToIdMap[folder];
}
});
const newFolders: FolderCollection = parsedFolders.reduce(
(acc, curr, index) => {
const id = folderNameToIdMap[curr];
const _newFolder: Folder = {
id,
name: curr,
expanded: false,
order: index,
};
return { [id]: _newFolder, ...acc };
},
{}
);
// increment the order of existing folders
const offset = parsedFolders.length;
const updatedFolders = useStore.getState().folders;
Object.values(updatedFolders).forEach((f) => (f.order += offset));
setFolders({ ...newFolders, ...updatedFolders });
// import chats
const prevChats = useStore.getState().chats;
if (prevChats) {
const updatedChats: ChatInterface[] = JSON.parse(
JSON.stringify(prevChats)
);
setChats(parsedData.concat(updatedChats));
} else {
setChats(parsedData);
}
setAlert({ message: 'Succesfully imported!', success: true });
} else {
setAlert({
message: 'Invalid chats data format',
success: false,
});
}
} else {
switch ((parsedData as ExportBase).version) {
case 1:
if (validateExportV1(parsedData)) {
// import folders
parsedData.folders;
// increment the order of existing folders
const offset = Object.keys(parsedData.folders).length;
const updatedFolders = useStore.getState().folders;
Object.values(updatedFolders).forEach(
(f) => (f.order += offset)
);
setFolders({ ...parsedData.folders, ...updatedFolders });
// import chats
const prevChats = useStore.getState().chats;
if (parsedData.chats) {
if (prevChats) {
const updatedChats: ChatInterface[] = JSON.parse(
JSON.stringify(prevChats)
);
setChats(parsedData.chats.concat(updatedChats));
} else {
setChats(parsedData.chats);
}
}
setAlert({ message: 'Succesfully imported!', success: true });
} else {
setAlert({
message: 'Invalid format',
success: false,
});
}
break;
}
}
} catch (error: unknown) {
setAlert({ message: (error as Error).message, success: false });
}
};
reader.readAsText(file);
}
};
return (
<>
<label className='block mb-2 text-sm font-medium text-gray-900 dark:text-gray-300'>
{t('import')} (JSON)
</label>
<input
className='w-full text-sm file:p-2 text-gray-800 file:text-gray-700 dark:text-gray-300 dark:file:text-gray-200 rounded-md cursor-pointer focus:outline-none bg-gray-50 file:bg-gray-100 dark:bg-gray-800 dark:file:bg-gray-700 file:border-0 border border-gray-300 dark:border-gray-600 placeholder-gray-900 dark:placeholder-gray-300 file:cursor-pointer'
type='file'
ref={inputRef}
/>
<button
className='btn btn-small btn-primary mt-3'
onClick={handleFileUpload}
>
{t('import')}
</button>
{alert && (
<div
className={`relative py-2 px-3 w-full mt-3 border rounded-md text-gray-600 dark:text-gray-100 text-sm whitespace-pre-wrap ${
alert.success
? 'border-green-500 bg-green-500/10'
: 'border-red-500 bg-red-500/10'
}`}
>
{alert.message}
</div>
)}
</>
);
};
export default ImportChat;

View file

@ -0,0 +1,78 @@
import React, { useRef } from 'react';
import { useTranslation } from 'react-i18next';
import useStore from '@store/store';
import { importOpenAIChatExport } from '@utils/import';
import { ChatInterface } from '@type/chat';
const ImportChatOpenAI = ({
setIsModalOpen,
}: {
setIsModalOpen: React.Dispatch<React.SetStateAction<boolean>>;
}) => {
const { t } = useTranslation();
const inputRef = useRef<HTMLInputElement>(null);
const setToastStatus = useStore((state) => state.setToastStatus);
const setToastMessage = useStore((state) => state.setToastMessage);
const setToastShow = useStore((state) => state.setToastShow);
const setChats = useStore.getState().setChats;
const handleFileUpload = () => {
if (!inputRef || !inputRef.current) return;
const file = inputRef.current.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (event) => {
const data = event.target?.result as string;
try {
const parsedData = JSON.parse(data);
const chats = importOpenAIChatExport(parsedData);
const prevChats: ChatInterface[] = JSON.parse(
JSON.stringify(useStore.getState().chats)
);
setChats(chats.concat(prevChats));
setToastStatus('success');
setToastMessage('Imported successfully!');
setIsModalOpen(false);
} catch (error: unknown) {
setToastStatus('error');
setToastMessage(`Invalid format! ${(error as Error).message}`);
}
setToastShow(true);
};
reader.readAsText(file);
};
return (
<>
<div className='text-lg font-bold text-gray-900 dark:text-gray-300 text-center mb-3'>
{t('import')} OpenAI ChatGPT {t('export')}
</div>
<label className='block mb-2 text-sm font-medium text-gray-900 dark:text-gray-300'>
{t('import')} (JSON)
</label>
<input
className='w-full text-sm file:p-2 text-gray-800 file:text-gray-700 dark:text-gray-300 dark:file:text-gray-200 rounded-md cursor-pointer focus:outline-none bg-gray-50 file:bg-gray-100 dark:bg-gray-800 dark:file:bg-gray-700 file:border-0 border border-gray-300 dark:border-gray-600 placeholder-gray-900 dark:placeholder-gray-300 file:cursor-pointer'
type='file'
ref={inputRef}
/>
<button
className='btn btn-small btn-primary mt-3'
onClick={handleFileUpload}
>
{t('import')}
</button>
</>
);
};
export default ImportChatOpenAI;

View file

@ -1,19 +1,12 @@
import React, { useRef, useState } from 'react'; import React, { useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { v4 as uuidv4 } from 'uuid';
import useStore from '@store/store';
import ExportIcon from '@icon/ExportIcon'; import ExportIcon from '@icon/ExportIcon';
import downloadFile from '@utils/downloadFile';
import { getToday } from '@utils/date';
import PopupModal from '@components/PopupModal'; import PopupModal from '@components/PopupModal';
import {
isLegacyImport, import ImportChat from './ImportChat';
validateAndFixChats, import ExportChat from './ExportChat';
validateExportV1, import ImportChatOpenAI from './ImportChatOpenAI';
} from '@utils/import';
import { ChatInterface, Folder, FolderCollection } from '@type/chat';
import Export, { ExportBase, ExportV1 } from '@type/export';
const ImportExportChat = () => { const ImportExportChat = () => {
const { t } = useTranslation(); const { t } = useTranslation();
@ -39,6 +32,8 @@ const ImportExportChat = () => {
<div className='p-6 border-b border-gray-200 dark:border-gray-600'> <div className='p-6 border-b border-gray-200 dark:border-gray-600'>
<ImportChat /> <ImportChat />
<ExportChat /> <ExportChat />
<div className='border-t my-3 border-gray-200 dark:border-gray-600' />
<ImportChatOpenAI setIsModalOpen={setIsModalOpen} />
</div> </div>
</PopupModal> </PopupModal>
)} )}
@ -46,185 +41,4 @@ const ImportExportChat = () => {
); );
}; };
const ImportChat = () => {
const { t } = useTranslation();
const setChats = useStore.getState().setChats;
const setFolders = useStore.getState().setFolders;
const inputRef = useRef<HTMLInputElement>(null);
const [alert, setAlert] = useState<{
message: string;
success: boolean;
} | null>(null);
const handleFileUpload = () => {
if (!inputRef || !inputRef.current) return;
const file = inputRef.current.files?.[0];
if (file) {
const reader = new FileReader();
reader.onload = (event) => {
const data = event.target?.result as string;
try {
const parsedData = JSON.parse(data);
if (isLegacyImport(parsedData)) {
if (validateAndFixChats(parsedData)) {
// import new folders
const folderNameToIdMap: Record<string, string> = {};
const parsedFolders: string[] = [];
parsedData.forEach((data) => {
const folder = data.folder;
if (folder) {
if (!parsedFolders.includes(folder)) {
parsedFolders.push(folder);
folderNameToIdMap[folder] = uuidv4();
}
data.folder = folderNameToIdMap[folder];
}
});
const newFolders: FolderCollection = parsedFolders.reduce(
(acc, curr, index) => {
const id = folderNameToIdMap[curr];
const _newFolder: Folder = {
id,
name: curr,
expanded: false,
order: index,
};
return { [id]: _newFolder, ...acc };
},
{}
);
// increment the order of existing folders
const offset = parsedFolders.length;
const updatedFolders = useStore.getState().folders;
Object.values(updatedFolders).forEach((f) => (f.order += offset));
setFolders({ ...newFolders, ...updatedFolders });
// import chats
const prevChats = useStore.getState().chats;
if (prevChats) {
const updatedChats: ChatInterface[] = JSON.parse(
JSON.stringify(prevChats)
);
setChats(parsedData.concat(updatedChats));
} else {
setChats(parsedData);
}
setAlert({ message: 'Succesfully imported!', success: true });
} else {
setAlert({
message: 'Invalid chats data format',
success: false,
});
}
} else {
switch ((parsedData as ExportBase).version) {
case 1:
if (validateExportV1(parsedData)) {
// import folders
parsedData.folders;
// increment the order of existing folders
const offset = Object.keys(parsedData.folders).length;
const updatedFolders = useStore.getState().folders;
Object.values(updatedFolders).forEach(
(f) => (f.order += offset)
);
setFolders({ ...parsedData.folders, ...updatedFolders });
// import chats
const prevChats = useStore.getState().chats;
if (parsedData.chats) {
if (prevChats) {
const updatedChats: ChatInterface[] = JSON.parse(
JSON.stringify(prevChats)
);
setChats(parsedData.chats.concat(updatedChats));
} else {
setChats(parsedData.chats);
}
}
setAlert({ message: 'Succesfully imported!', success: true });
} else {
setAlert({
message: 'Invalid format',
success: false,
});
}
break;
}
}
} catch (error: unknown) {
setAlert({ message: (error as Error).message, success: false });
}
};
reader.readAsText(file);
}
};
return (
<>
<label className='block mb-2 text-sm font-medium text-gray-900 dark:text-gray-300'>
{t('import')} (JSON)
</label>
<input
className='w-full text-sm file:p-2 text-gray-800 file:text-gray-700 dark:text-gray-300 dark:file:text-gray-200 rounded-md cursor-pointer focus:outline-none bg-gray-50 file:bg-gray-100 dark:bg-gray-800 dark:file:bg-gray-700 file:border-0 border border-gray-300 dark:border-gray-600 placeholder-gray-900 dark:placeholder-gray-300 file:cursor-pointer'
type='file'
ref={inputRef}
/>
<button
className='btn btn-small btn-primary mt-3'
onClick={handleFileUpload}
>
{t('import')}
</button>
{alert && (
<div
className={`relative py-2 px-3 w-full mt-3 border rounded-md text-gray-600 dark:text-gray-100 text-sm whitespace-pre-wrap ${
alert.success
? 'border-green-500 bg-green-500/10'
: 'border-red-500 bg-red-500/10'
}`}
>
{alert.message}
</div>
)}
</>
);
};
const ExportChat = () => {
const { t } = useTranslation();
return (
<div className='mt-6'>
<div className='block mb-2 text-sm font-medium text-gray-900 dark:text-gray-300'>
{t('export')} (JSON)
</div>
<button
className='btn btn-small btn-primary'
onClick={() => {
const fileData: Export = {
chats: useStore.getState().chats,
folders: useStore.getState().folders,
version: 1,
};
downloadFile(fileData, getToday());
}}
>
{t('export')}
</button>
</div>
);
};
export default ImportExportChat; export default ImportExportChat;

View file

@ -1,4 +1,4 @@
import { ChatInterface, FolderCollection } from './chat'; import { ChatInterface, FolderCollection, Role } from './chat';
export interface ExportBase { export interface ExportBase {
version: number; version: number;
@ -9,4 +9,24 @@ export interface ExportV1 extends ExportBase {
folders: FolderCollection; folders: FolderCollection;
} }
export type OpenAIChat = {
title: string;
mapping: {
[key: string]: {
id: string;
message: {
author: {
role: Role;
};
content: {
parts: string[];
};
} | null;
parent: string | null;
children: string[];
};
};
current_node: string;
};
export default ExportV1; export default ExportV1;

View file

@ -12,7 +12,7 @@ import {
modelOptions, modelOptions,
_defaultChatConfig, _defaultChatConfig,
} from '@constants/chat'; } from '@constants/chat';
import { ExportV1 } from '@type/export'; import { ExportV1, OpenAIChat } from '@type/export';
export const validateAndFixChats = (chats: any): chats is ChatInterface[] => { export const validateAndFixChats = (chats: any): chats is ChatInterface[] => {
if (!Array.isArray(chats)) return false; if (!Array.isArray(chats)) return false;
@ -88,3 +88,45 @@ export const validateFolders = (
export const validateExportV1 = (data: ExportV1): data is ExportV1 => { export const validateExportV1 = (data: ExportV1): data is ExportV1 => {
return validateAndFixChats(data.chats) && validateFolders(data.folders); return validateAndFixChats(data.chats) && validateFolders(data.folders);
}; };
// Convert OpenAI chat format to BetterChatGPT format
export const convertOpenAIToBetterChatGPTFormat = (
openAIChat: OpenAIChat
): ChatInterface => {
const messages: MessageInterface[] = [];
// Traverse the chat tree and collect messages
const traverseTree = (id: string) => {
const node = openAIChat.mapping[id];
// Extract message if it exists
if (node.message) {
const { role } = node.message.author;
const content = node.message.content.parts.join('');
if (content.length > 0) messages.push({ role, content });
}
// Traverse the last child node if any children exist
if (node.children.length > 0) {
traverseTree(node.children[node.children.length - 1]);
}
};
// Start traversing the tree from the root node
const rootNode = openAIChat.mapping[Object.keys(openAIChat.mapping)[0]].id;
traverseTree(rootNode);
// Return the chat interface object
return {
id: uuidv4(),
title: openAIChat.title,
messages,
config: _defaultChatConfig,
titleSet: true,
};
};
// Import OpenAI chat data and convert it to BetterChatGPT format
export const importOpenAIChatExport = (openAIChatExport: OpenAIChat[]) => {
return openAIChatExport.map(convertOpenAIToBetterChatGPTFormat);
};