Sync to Google Drive (#233)

* google drive api

* fix: google-api

* GoogleCloudStorage

* list files api

* Google Cloud Storage

* move button to side menu

* sync status

* rename file

* show popup for those with cloud sync

* update button style

* auto close modal after logged in

* auto popup every 59min

* set as unauthenticated if update fails

* i18n

* add spin animation

* feat: Toast

* clear toast

* electron: desktop google drive integration

This update includes integration with Google Drive for desktop access,
but requires a new URL, which may cause existing chat data to be lost.
To minimize disruption, users can export their current chat data and
import it into the newer version.

* update note

* error handling

* support multiple drive files

* feat: delete drive file

* i18n

* change style
This commit is contained in:
Jing Hua 2023-04-14 15:29:13 +08:00 committed by GitHub
parent 02697408ce
commit 3f0ada4a9d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
40 changed files with 1407 additions and 29 deletions

View file

@ -3,3 +3,4 @@ VITE_CUSTOM_API_ENDPOINT=
VITE_DEFAULT_API_ENDPOINT= VITE_DEFAULT_API_ENDPOINT=
VITE_OPENAI_API_KEY= VITE_OPENAI_API_KEY=
VITE_DEFAULT_SYSTEM_MESSAGE= # Remove this line if you want to use the default system message of Better ChatGPT VITE_DEFAULT_SYSTEM_MESSAGE= # Remove this line if you want to use the default system message of Better ChatGPT
VITE_GOOGLE_CLIENT_ID= # for syncing data with google drive

View file

@ -6,6 +6,8 @@ const { autoUpdater } = require('electron-updater');
if (require('electron-squirrel-startup')) app.quit(); if (require('electron-squirrel-startup')) app.quit();
const PORT = isDev ? '5173' : '51735';
function createWindow() { function createWindow() {
let iconPath = ''; let iconPath = '';
if (isDev) { if (isDev) {
@ -30,11 +32,9 @@ function createWindow() {
win.maximize(); win.maximize();
win.show(); win.show();
win.loadURL( isDev || createServer();
isDev
? 'http://localhost:5173' win.loadURL(`http://localhost:${PORT}`);
: `file://${path.join(__dirname, '../dist/index.html')}`
);
if (isDev) { if (isDev) {
win.webContents.openDevTools({ mode: 'detach' }); win.webContents.openDevTools({ mode: 'detach' });
@ -81,3 +81,67 @@ app.on('activate', () => {
createWindow(); createWindow();
} }
}); });
const createServer = () => {
// Dependencies
const http = require('http');
const fs = require('fs');
const path = require('path');
// MIME types for different file extensions
const mimeTypes = {
'.html': 'text/html',
'.css': 'text/css',
'.js': 'text/javascript',
'.wasm': 'application/wasm',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.png': 'image/png',
'.gif': 'image/gif',
'.svg': 'image/svg+xml',
'.json': 'application/json',
};
// Create a http server
const server = http.createServer((request, response) => {
// Get the file path from the URL
let filePath =
request.url === '/' ? '../dist/index.html' : `../dist/${request.url}`;
// Get the file extension from the filePath
let extname = path.extname(filePath);
// Set the default MIME type to text/plain
let contentType = 'text/plain';
// Check if the file extension is in the MIME types object
if (extname in mimeTypes) {
contentType = mimeTypes[extname];
}
// Read the file from the disk
fs.readFile(filePath, (error, content) => {
if (error) {
// If file read error occurs
if (error.code === 'ENOENT') {
// File not found error
response.writeHead(404);
response.end('File Not Found');
} else {
// Server error
response.writeHead(500);
response.end(`Server Error: ${error.code}`);
}
} else {
// File read successful
response.writeHead(200, { 'Content-Type': contentType });
response.end(content, 'utf-8');
}
});
});
// Listen for request on port ${PORT}
server.listen(PORT, () => {
console.log(`Server listening on http://localhost:${PORT}/`);
});
};

View file

@ -41,6 +41,7 @@
}, },
"dependencies": { "dependencies": {
"@dqbd/tiktoken": "^1.0.2", "@dqbd/tiktoken": "^1.0.2",
"@react-oauth/google": "^0.9.0",
"electron-is-dev": "^2.0.0", "electron-is-dev": "^2.0.0",
"electron-squirrel-startup": "^1.0.0", "electron-squirrel-startup": "^1.0.0",
"electron-updater": "^5.3.0", "electron-updater": "^5.3.0",

View file

@ -0,0 +1,16 @@
{
"name": "Google Sync",
"tagline": "Effortlessly synchronize your chats and settings with Google Drive.",
"button": {
"sync": "Sync your chats",
"stop": "Stop syncing",
"create": "Create new file",
"confirm": "Confirm selection"
},
"notice": "Note: You will need to re-login on every visit or every hour. To avoid your cloud data being overwritten, do not use BetterChatGPT on more than one device at the same time.",
"privacy": "Your privacy is important to us, and to ensure it, Better ChatGPT only has non-sensitive access, meaning it can only create, view, and manage its own files and folders.",
"toast": {
"sync": "Sync successful!",
"stop": "Syncing stopped"
}
}

View file

@ -0,0 +1,16 @@
{
"name": "Google Sync",
"tagline": "Effortlessly synchronize your chats and settings with Google Drive.",
"button": {
"sync": "Sync your chats",
"stop": "Stop syncing",
"create": "Create new file",
"confirm": "Confirm selection"
},
"notice": "Note: You will need to re-login on every visit or every hour. To avoid your cloud data being overwritten, do not use BetterChatGPT on more than one device at the same time.",
"privacy": "Your privacy is important to us, and to ensure it, Better ChatGPT only has non-sensitive access, meaning it can only create, view, and manage its own files and folders.",
"toast": {
"sync": "Sync successful!",
"stop": "Syncing stopped"
}
}

View file

@ -0,0 +1,16 @@
{
"name": "Google Sync",
"tagline": "Effortlessly synchronize your chats and settings with Google Drive.",
"button": {
"sync": "Sync your chats",
"stop": "Stop syncing",
"create": "Create new file",
"confirm": "Confirm selection"
},
"notice": "Note: You will need to re-login on every visit or every hour. To avoid your cloud data being overwritten, do not use BetterChatGPT on more than one device at the same time.",
"privacy": "Your privacy is important to us, and to ensure it, Better ChatGPT only has non-sensitive access, meaning it can only create, view, and manage its own files and folders.",
"toast": {
"sync": "Sync successful!",
"stop": "Syncing stopped"
}
}

View file

@ -0,0 +1,16 @@
{
"name": "Google Sync",
"tagline": "Effortlessly synchronize your chats and settings with Google Drive.",
"button": {
"sync": "Sync your chats",
"stop": "Stop syncing",
"create": "Create new file",
"confirm": "Confirm selection"
},
"notice": "Note: You will need to re-login on every visit or every hour. To avoid your cloud data being overwritten, do not use BetterChatGPT on more than one device at the same time.",
"privacy": "Your privacy is important to us, and to ensure it, Better ChatGPT only has non-sensitive access, meaning it can only create, view, and manage its own files and folders.",
"toast": {
"sync": "Sync successful!",
"stop": "Syncing stopped"
}
}

View file

@ -0,0 +1,16 @@
{
"name": "Google Sync",
"tagline": "Effortlessly synchronize your chats and settings with Google Drive.",
"button": {
"sync": "Sync your chats",
"stop": "Stop syncing",
"create": "Create new file",
"confirm": "Confirm selection"
},
"notice": "Note: You will need to re-login on every visit or every hour. To avoid your cloud data being overwritten, do not use BetterChatGPT on more than one device at the same time.",
"privacy": "Your privacy is important to us, and to ensure it, Better ChatGPT only has non-sensitive access, meaning it can only create, view, and manage its own files and folders.",
"toast": {
"sync": "Sync successful!",
"stop": "Syncing stopped"
}
}

View file

@ -0,0 +1,16 @@
{
"name": "Google Sync",
"tagline": "Effortlessly synchronize your chats and settings with Google Drive.",
"button": {
"sync": "Sync your chats",
"stop": "Stop syncing",
"create": "Create new file",
"confirm": "Confirm selection"
},
"notice": "Note: You will need to re-login on every visit or every hour. To avoid your cloud data being overwritten, do not use BetterChatGPT on more than one device at the same time.",
"privacy": "Your privacy is important to us, and to ensure it, Better ChatGPT only has non-sensitive access, meaning it can only create, view, and manage its own files and folders.",
"toast": {
"sync": "Sync successful!",
"stop": "Syncing stopped"
}
}

View file

@ -0,0 +1,16 @@
{
"name": "Google Sync",
"tagline": "Effortlessly synchronize your chats and settings with Google Drive.",
"button": {
"sync": "Sync your chats",
"stop": "Stop syncing",
"create": "Create new file",
"confirm": "Confirm selection"
},
"notice": "Note: You will need to re-login on every visit or every hour. To avoid your cloud data being overwritten, do not use BetterChatGPT on more than one device at the same time.",
"privacy": "Your privacy is important to us, and to ensure it, Better ChatGPT only has non-sensitive access, meaning it can only create, view, and manage its own files and folders.",
"toast": {
"sync": "Sync successful!",
"stop": "Syncing stopped"
}
}

View file

@ -0,0 +1,16 @@
{
"name": "Google Sync",
"tagline": "Effortlessly synchronize your chats and settings with Google Drive.",
"button": {
"sync": "Sync your chats",
"stop": "Stop syncing",
"create": "Create new file",
"confirm": "Confirm selection"
},
"notice": "Note: You will need to re-login on every visit or every hour. To avoid your cloud data being overwritten, do not use BetterChatGPT on more than one device at the same time.",
"privacy": "Your privacy is important to us, and to ensure it, Better ChatGPT only has non-sensitive access, meaning it can only create, view, and manage its own files and folders.",
"toast": {
"sync": "Sync successful!",
"stop": "Syncing stopped"
}
}

View file

@ -0,0 +1,16 @@
{
"name": "Google Sync",
"tagline": "Effortlessly synchronize your chats and settings with Google Drive.",
"button": {
"sync": "Sync your chats",
"stop": "Stop syncing",
"create": "Create new file",
"confirm": "Confirm selection"
},
"notice": "Note: You will need to re-login on every visit or every hour. To avoid your cloud data being overwritten, do not use BetterChatGPT on more than one device at the same time.",
"privacy": "Your privacy is important to us, and to ensure it, Better ChatGPT only has non-sensitive access, meaning it can only create, view, and manage its own files and folders.",
"toast": {
"sync": "Sync successful!",
"stop": "Syncing stopped"
}
}

View file

@ -0,0 +1,16 @@
{
"name": "Google Sync",
"tagline": "Effortlessly synchronize your chats and settings with Google Drive.",
"button": {
"sync": "Sync your chats",
"stop": "Stop syncing",
"create": "Create new file",
"confirm": "Confirm selection"
},
"notice": "Note: You will need to re-login on every visit or every hour. To avoid your cloud data being overwritten, do not use BetterChatGPT on more than one device at the same time.",
"privacy": "Your privacy is important to us, and to ensure it, Better ChatGPT only has non-sensitive access, meaning it can only create, view, and manage its own files and folders.",
"toast": {
"sync": "Sync successful!",
"stop": "Syncing stopped"
}
}

View file

@ -0,0 +1,16 @@
{
"name": "Google Sync",
"tagline": "Effortlessly synchronize your chats and settings with Google Drive.",
"button": {
"sync": "Sync your chats",
"stop": "Stop syncing",
"create": "Create new file",
"confirm": "Confirm selection"
},
"notice": "Note: You will need to re-login on every visit or every hour. To avoid your cloud data being overwritten, do not use BetterChatGPT on more than one device at the same time.",
"privacy": "Your privacy is important to us, and to ensure it, Better ChatGPT only has non-sensitive access, meaning it can only create, view, and manage its own files and folders.",
"toast": {
"sync": "Sync successful!",
"stop": "Syncing stopped"
}
}

View file

@ -0,0 +1,16 @@
{
"name": "Google Sync",
"tagline": "Effortlessly synchronize your chats and settings with Google Drive.",
"button": {
"sync": "Sync your chats",
"stop": "Stop syncing",
"create": "Create new file",
"confirm": "Confirm selection"
},
"notice": "Note: You will need to re-login on every visit or every hour. To avoid your cloud data being overwritten, do not use BetterChatGPT on more than one device at the same time.",
"privacy": "Your privacy is important to us, and to ensure it, Better ChatGPT only has non-sensitive access, meaning it can only create, view, and manage its own files and folders.",
"toast": {
"sync": "Sync successful!",
"stop": "Syncing stopped"
}
}

View file

@ -0,0 +1,16 @@
{
"name": "Google Sync",
"tagline": "Effortlessly synchronize your chats and settings with Google Drive.",
"button": {
"sync": "Sync your chats",
"stop": "Stop syncing",
"create": "Create new file",
"confirm": "Confirm selection"
},
"notice": "Note: You will need to re-login on every visit or every hour. To avoid your cloud data being overwritten, do not use BetterChatGPT on more than one device at the same time.",
"privacy": "Your privacy is important to us, and to ensure it, Better ChatGPT only has non-sensitive access, meaning it can only create, view, and manage its own files and folders.",
"toast": {
"sync": "Sync successful!",
"stop": "Syncing stopped"
}
}

View file

@ -9,6 +9,7 @@ import useInitialiseNewChat from '@hooks/useInitialiseNewChat';
import { ChatInterface } from '@type/chat'; import { ChatInterface } from '@type/chat';
import { Theme } from '@type/theme'; import { Theme } from '@type/theme';
import ApiPopup from '@components/ApiPopup'; import ApiPopup from '@components/ApiPopup';
import Toast from '@components/Toast';
function App() { function App() {
const initialiseNewChat = useInitialiseNewChat(); const initialiseNewChat = useInitialiseNewChat();
@ -78,6 +79,7 @@ function App() {
<Menu /> <Menu />
<Chat /> <Chat />
<ApiPopup /> <ApiPopup />
<Toast />
</div> </div>
); );
} }

191
src/api/google-api.ts Normal file
View file

@ -0,0 +1,191 @@
import { debounce } from 'lodash';
import { StorageValue } from 'zustand/middleware';
import useStore from '@store/store';
import useCloudAuthStore from '@store/cloud-auth-store';
import {
GoogleTokenInfo,
GoogleFileResource,
GoogleFileList,
} from '@type/google-api';
import PersistStorageState from '@type/persist';
import { createMultipartRelatedBody } from './helper';
export const createDriveFile = async (
file: File,
accessToken: string
): Promise<GoogleFileResource> => {
const boundary = 'better_chatgpt';
const metadata = {
name: file.name,
mimeType: file.type,
};
const requestBody = createMultipartRelatedBody(metadata, file, boundary);
const response = await fetch(
'https://www.googleapis.com/upload/drive/v3/files?uploadType=multipart',
{
method: 'POST',
headers: {
Authorization: `Bearer ${accessToken}`,
'Content-Type': `multipart/related; boundary=${boundary}`,
'Content-Length': requestBody.size.toString(),
},
body: requestBody,
}
);
if (response.ok) {
const result: GoogleFileResource = await response.json();
return result;
} else {
throw new Error(
`Error uploading file: ${response.status} ${response.statusText}`
);
}
};
export const getDriveFile = async <S>(
fileId: string,
accessToken: string
): Promise<StorageValue<S>> => {
const response = await fetch(
`https://content.googleapis.com/drive/v3/files/${fileId}?alt=media`,
{
method: 'GET',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${accessToken}`,
},
}
);
const result: StorageValue<S> = await response.json();
return result;
};
export const getDriveFileTyped = async (
fileId: string,
accessToken: string
): Promise<StorageValue<PersistStorageState>> => {
return await getDriveFile(fileId, accessToken);
};
export const listDriveFiles = async (
accessToken: string
): Promise<GoogleFileList> => {
const response = await fetch(
'https://www.googleapis.com/drive/v3/files?orderBy=createdTime desc',
{
method: 'GET',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${accessToken}`,
},
}
);
if (!response.ok) {
throw new Error(
`Error listing google drive files: ${response.status} ${response.statusText}`
);
}
const result: GoogleFileList = await response.json();
return result;
};
export const updateDriveFile = async (
file: File,
fileId: string,
accessToken: string
): Promise<GoogleFileResource> => {
const response = await fetch(
`https://www.googleapis.com/upload/drive/v3/files/${fileId}`,
{
method: 'PATCH',
headers: {
Authorization: `Bearer ${accessToken}`,
},
body: file,
}
);
if (response.ok) {
const result: GoogleFileResource = await response.json();
return result;
} else {
throw new Error(
`Error uploading file: ${response.status} ${response.statusText}`
);
}
};
export const updateDriveFileName = async (
fileName: string,
fileId: string,
accessToken: string
) => {
const response = await fetch(
`https://www.googleapis.com/drive/v3/files/${fileId}`,
{
method: 'PATCH',
headers: {
Authorization: `Bearer ${accessToken}`,
},
body: JSON.stringify({ name: fileName }),
}
);
if (response.ok) {
const result: GoogleFileResource = await response.json();
return result;
} else {
throw new Error(
`Error updating file name: ${response.status} ${response.statusText}`
);
}
};
export const deleteDriveFile = async (fileId: string, accessToken: string) => {
const response = await fetch(
`https://www.googleapis.com/drive/v3/files/${fileId}`,
{
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${accessToken}`,
},
}
);
if (response.ok) {
return true;
} else {
throw new Error(
`Error deleting file name: ${response.status} ${response.statusText}`
);
}
};
export const validateGoogleOath2AccessToken = async (accessToken: string) => {
const response = await fetch(
`https://oauth2.googleapis.com/tokeninfo?access_token=${accessToken}`
);
if (!response.ok) return false;
const result: GoogleTokenInfo = await response.json();
return result;
};
export const updateDriveFileDebounced = debounce(
async (file: File, fileId: string, accessToken: string) => {
try {
const result = await updateDriveFile(file, fileId, accessToken);
useCloudAuthStore.getState().setSyncStatus('synced');
return result;
} catch (e: unknown) {
useStore.getState().setToastMessage((e as Error).message);
useStore.getState().setToastShow(true);
useStore.getState().setToastStatus('error');
useCloudAuthStore.getState().setSyncStatus('unauthenticated');
}
},
5000
);

View file

@ -21,3 +21,25 @@ export const parseEventSource = (
}); });
return result; return result;
}; };
export const createMultipartRelatedBody = (
metadata: object,
file: File,
boundary: string
): Blob => {
const encoder = new TextEncoder();
const metadataPart = encoder.encode(
`--${boundary}\r\nContent-Type: application/json; charset=UTF-8\r\n\r\n${JSON.stringify(
metadata
)}\r\n`
);
const filePart = encoder.encode(
`--${boundary}\r\nContent-Type: ${file.type}\r\n\r\n`
);
const endBoundary = encoder.encode(`\r\n--${boundary}--`);
return new Blob([metadataPart, filePart, file, endBoundary], {
type: 'multipart/related; boundary=' + boundary,
});
};

View file

@ -0,0 +1,17 @@
import React from 'react';
const GoogleIcon = (props: React.SVGProps<SVGSVGElement>) => {
return (
<svg
fill='currentColor'
viewBox='0 0 16 16'
height='1em'
width='1em'
{...props}
>
<path d='M15.545 6.558a9.42 9.42 0 01.139 1.626c0 2.434-.87 4.492-2.384 5.885h.002C11.978 15.292 10.158 16 8 16A8 8 0 118 0a7.689 7.689 0 015.352 2.082l-2.284 2.284A4.347 4.347 0 008 3.166c-2.087 0-3.86 1.408-4.492 3.304a4.792 4.792 0 000 3.063h.003c.635 1.893 2.405 3.301 4.492 3.301 1.078 0 2.004-.276 2.722-.764h-.003a3.702 3.702 0 001.599-2.431H8v-3.08h7.545z' />
</svg>
);
};
export default GoogleIcon;

View file

@ -1,6 +1,6 @@
import React from 'react'; import React from 'react';
const RefreshIcon = () => { const RefreshIcon = (props: React.SVGProps<SVGSVGElement>) => {
return ( return (
<svg <svg
stroke='currentColor' stroke='currentColor'
@ -13,6 +13,7 @@ const RefreshIcon = () => {
height='1em' height='1em'
width='1em' width='1em'
xmlns='http://www.w3.org/2000/svg' xmlns='http://www.w3.org/2000/svg'
{...props}
> >
<polyline points='1 4 1 10 7 10'></polyline> <polyline points='1 4 1 10 7 10'></polyline>
<polyline points='23 20 23 14 17 14'></polyline> <polyline points='23 20 23 14 17 14'></polyline>

View file

@ -1,6 +1,6 @@
import React from 'react'; import React from 'react';
const TickIcon = () => { const TickIcon = (props: React.SVGProps<SVGSVGElement>) => {
return ( return (
<svg <svg
stroke='currentColor' stroke='currentColor'
@ -13,6 +13,7 @@ const TickIcon = () => {
height='1em' height='1em'
width='1em' width='1em'
xmlns='http://www.w3.org/2000/svg' xmlns='http://www.w3.org/2000/svg'
{...props}
> >
<polyline points='20 6 9 17 4 12'></polyline> <polyline points='20 6 9 17 4 12'></polyline>
</svg> </svg>

View file

@ -0,0 +1,379 @@
import React, { useEffect, useState } from 'react';
import { GoogleOAuthProvider } from '@react-oauth/google';
import { useTranslation } from 'react-i18next';
import useStore from '@store/store';
import useGStore from '@store/cloud-auth-store';
import {
createDriveFile,
deleteDriveFile,
updateDriveFileName,
validateGoogleOath2AccessToken,
} from '@api/google-api';
import { getFiles, stateToFile } from '@utils/google-api';
import createGoogleCloudStorage from '@store/storage/GoogleCloudStorage';
import GoogleSyncButton from './GoogleSyncButton';
import PopupModal from '@components/PopupModal';
import GoogleIcon from '@icon/GoogleIcon';
import TickIcon from '@icon/TickIcon';
import RefreshIcon from '@icon/RefreshIcon';
import { GoogleFileResource, SyncStatus } from '@type/google-api';
import EditIcon from '@icon/EditIcon';
import CrossIcon from '@icon/CrossIcon';
import DeleteIcon from '@icon/DeleteIcon';
const GoogleSync = ({ clientId }: { clientId: string }) => {
const { t } = useTranslation(['drive']);
const fileId = useGStore((state) => state.fileId);
const setFileId = useGStore((state) => state.setFileId);
const googleAccessToken = useGStore((state) => state.googleAccessToken);
const syncStatus = useGStore((state) => state.syncStatus);
const cloudSync = useGStore((state) => state.cloudSync);
const setSyncStatus = useGStore((state) => state.setSyncStatus);
const [isModalOpen, setIsModalOpen] = useState<boolean>(cloudSync);
const [files, setFiles] = useState<GoogleFileResource[]>([]);
const initialiseState = async (_googleAccessToken: string) => {
const validated = await validateGoogleOath2AccessToken(_googleAccessToken);
if (validated) {
try {
const _files = await getFiles(_googleAccessToken);
if (_files) {
setFiles(_files);
if (_files.length === 0) {
// _files is empty, create new file in google drive and set the file id
const googleFile = await createDriveFile(
stateToFile(),
_googleAccessToken
);
setFileId(googleFile.id);
} else {
if (_files.findIndex((f) => f.id === fileId) !== -1) {
// local storage file id matches one of the file ids returned
setFileId(fileId);
} else {
// default set file id to the latest one
setFileId(_files[0].id);
}
}
useStore.persist.setOptions({
storage: createGoogleCloudStorage(),
});
useStore.persist.rehydrate();
}
} catch (e: unknown) {
console.log(e);
}
} else {
setSyncStatus('unauthenticated');
}
};
useEffect(() => {
if (googleAccessToken) {
setSyncStatus('syncing');
initialiseState(googleAccessToken);
}
}, [googleAccessToken]);
return (
<GoogleOAuthProvider clientId={clientId}>
<div
className='flex py-3 px-3 items-center gap-3 rounded-md hover:bg-gray-500/10 transition-colors duration-200 text-white cursor-pointer text-sm'
onClick={() => {
setIsModalOpen(true);
}}
>
<GoogleIcon /> {t('name')}
{cloudSync && <SyncIcon status={syncStatus} />}
</div>
{isModalOpen && (
<GooglePopup
setIsModalOpen={setIsModalOpen}
files={files}
setFiles={setFiles}
/>
)}
</GoogleOAuthProvider>
);
};
const GooglePopup = ({
setIsModalOpen,
files,
setFiles,
}: {
setIsModalOpen: React.Dispatch<React.SetStateAction<boolean>>;
files: GoogleFileResource[];
setFiles: React.Dispatch<React.SetStateAction<GoogleFileResource[]>>;
}) => {
const { t } = useTranslation(['drive']);
const syncStatus = useGStore((state) => state.syncStatus);
const setSyncStatus = useGStore((state) => state.setSyncStatus);
const cloudSync = useGStore((state) => state.cloudSync);
const googleAccessToken = useGStore((state) => state.googleAccessToken);
const setFileId = useGStore((state) => state.setFileId);
const setToastStatus = useStore((state) => state.setToastStatus);
const setToastMessage = useStore((state) => state.setToastMessage);
const setToastShow = useStore((state) => state.setToastShow);
const [_fileId, _setFileId] = useState<string>(
useGStore.getState().fileId || ''
);
const createSyncFile = async () => {
if (!googleAccessToken) return;
try {
setSyncStatus('syncing');
await createDriveFile(stateToFile(), googleAccessToken);
const _files = await getFiles(googleAccessToken);
if (_files) setFiles(_files);
setSyncStatus('synced');
} catch (e: unknown) {
setSyncStatus('unauthenticated');
setToastMessage((e as Error).message);
setToastShow(true);
setToastStatus('error');
}
};
return (
<PopupModal
title={t('name') as string}
setIsModalOpen={setIsModalOpen}
cancelButton={false}
>
<div className='p-6 border-b border-gray-200 dark:border-gray-600 text-gray-900 dark:text-gray-300 text-sm flex flex-col items-center gap-4 text-center'>
<p>{t('tagline')}</p>
<GoogleSyncButton
loginHandler={() => {
setIsModalOpen(false);
window.setTimeout(() => {
setIsModalOpen(true);
}, 3540000); // timeout - 3540000ms = 59 min (access token last 60 min)
}}
/>
<p className='border border-gray-400 px-3 py-2 rounded-md'>
{t('notice')}
</p>
{cloudSync && syncStatus !== 'unauthenticated' && (
<div className='flex flex-col gap-2 items-center'>
{files.map((file) => (
<FileSelector
id={file.id}
name={file.name}
_fileId={_fileId}
_setFileId={_setFileId}
setFiles={setFiles}
key={file.id}
/>
))}
{syncStatus !== 'syncing' && (
<div className='flex gap-4 flex-wrap justify-center'>
<div
className='btn btn-primary cursor-pointer'
onClick={async () => {
setFileId(_fileId);
await useStore.persist.rehydrate();
setToastStatus('success');
setToastMessage(t('toast.sync'));
setToastShow(true);
setIsModalOpen(false);
}}
>
{t('button.confirm')}
</div>
<div
className='btn btn-neutral cursor-pointer'
onClick={createSyncFile}
>
{t('button.create')}
</div>
</div>
)}
<div className='h-4 w-4'>
{syncStatus === 'syncing' && <SyncIcon status='syncing' />}
</div>
</div>
)}
<p>{t('privacy')}</p>
</div>
</PopupModal>
);
};
const FileSelector = ({
name,
id,
_fileId,
_setFileId,
setFiles,
}: {
name: string;
id: string;
_fileId: string;
_setFileId: React.Dispatch<React.SetStateAction<string>>;
setFiles: React.Dispatch<React.SetStateAction<GoogleFileResource[]>>;
}) => {
const syncStatus = useGStore((state) => state.syncStatus);
const setSyncStatus = useGStore((state) => state.setSyncStatus);
const setToastStatus = useStore((state) => state.setToastStatus);
const setToastMessage = useStore((state) => state.setToastMessage);
const setToastShow = useStore((state) => state.setToastShow);
const [isEditing, setIsEditing] = useState<boolean>(false);
const [isDeleting, setIsDeleting] = useState<boolean>(false);
const [_name, _setName] = useState<string>(name);
const syncing = syncStatus === 'syncing';
const updateFileName = async () => {
if (syncing) return;
setIsEditing(false);
const accessToken = useGStore.getState().googleAccessToken;
if (!accessToken) return;
try {
setSyncStatus('syncing');
const newFileName = _name.endsWith('.json') ? _name : _name + '.json';
await updateDriveFileName(newFileName, id, accessToken);
const _files = await getFiles(accessToken);
if (_files) setFiles(_files);
setSyncStatus('synced');
} catch (e: unknown) {
setSyncStatus('unauthenticated');
setToastMessage((e as Error).message);
setToastShow(true);
setToastStatus('error');
}
};
const deleteFile = async () => {
if (syncing) return;
setIsDeleting(false);
const accessToken = useGStore.getState().googleAccessToken;
if (!accessToken) return;
try {
setSyncStatus('syncing');
await deleteDriveFile(id, accessToken);
const _files = await getFiles(accessToken);
if (_files) setFiles(_files);
setSyncStatus('synced');
} catch (e: unknown) {
setSyncStatus('unauthenticated');
setToastMessage((e as Error).message);
setToastShow(true);
setToastStatus('error');
}
};
return (
<label
className={`w-full flex items-center justify-between mb-2 gap-2 text-sm font-medium text-gray-900 dark:text-gray-300 ${
syncing ? 'cursor-not-allowed opacity-40' : ''
}`}
>
<input
type='radio'
checked={_fileId === id}
className='w-4 h-4'
onChange={() => {
if (!syncing) _setFileId(id);
}}
disabled={syncing}
/>
<div className='flex-1 text-left'>
{isEditing ? (
<input
type='text'
className='text-gray-800 dark:text-white p-3 text-sm border-none bg-gray-200 dark:bg-gray-600 rounded-md m-0 w-full mr-0 h-8 focus:outline-none'
value={_name}
onChange={(e) => {
_setName(e.target.value);
}}
/>
) : (
<>
{name} <div className='text-[10px] md:text-xs'>{`<${id}>`}</div>
</>
)}
</div>
{isEditing || isDeleting ? (
<div className='flex gap-1'>
<div
className={`${syncing ? 'cursor-not-allowed' : 'cursor-pointer'}`}
onClick={() => {
if (isEditing) updateFileName();
if (isDeleting) deleteFile();
}}
>
<TickIcon />
</div>
<div
className={`${syncing ? 'cursor-not-allowed' : 'cursor-pointer'}`}
onClick={() => {
if (!syncing) {
setIsEditing(false);
setIsDeleting(false);
}
}}
>
<CrossIcon />
</div>
</div>
) : (
<div className='flex gap-1'>
<div
className={`${syncing ? 'cursor-not-allowed' : 'cursor-pointer'}`}
onClick={() => {
if (!syncing) setIsEditing(true);
}}
>
<EditIcon />
</div>
<div
className={`${syncing ? 'cursor-not-allowed' : 'cursor-pointer'}`}
onClick={() => {
if (!syncing) setIsDeleting(true);
}}
>
<DeleteIcon />
</div>
</div>
)}
</label>
);
};
const SyncIcon = ({ status }: { status: SyncStatus }) => {
const statusToIcon = {
unauthenticated: (
<div className='bg-red-600/80 rounded-full w-4 h-4 text-xs flex justify-center items-center'>
!
</div>
),
syncing: (
<div className='bg-gray-600/80 rounded-full p-1 animate-spin'>
<RefreshIcon className='h-2 w-2' />
</div>
),
synced: (
<div className='bg-gray-600/80 rounded-full p-1'>
<TickIcon className='h-2 w-2' />
</div>
),
};
return statusToIcon[status] || null;
};
export default GoogleSync;

View file

@ -0,0 +1,67 @@
import React, { useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { useGoogleLogin, googleLogout } from '@react-oauth/google';
import useGStore from '@store/cloud-auth-store';
import useStore from '@store/store';
import { createJSONStorage } from 'zustand/middleware';
const GoogleSyncButton = ({ loginHandler }: { loginHandler?: () => void }) => {
const { t } = useTranslation(['drive']);
const setGoogleAccessToken = useGStore((state) => state.setGoogleAccessToken);
const setSyncStatus = useGStore((state) => state.setSyncStatus);
const setCloudSync = useGStore((state) => state.setCloudSync);
const cloudSync = useGStore((state) => state.cloudSync);
const setToastStatus = useStore((state) => state.setToastStatus);
const setToastMessage = useStore((state) => state.setToastMessage);
const setToastShow = useStore((state) => state.setToastShow);
const login = useGoogleLogin({
onSuccess: (codeResponse) => {
setGoogleAccessToken(codeResponse.access_token);
setCloudSync(true);
loginHandler && loginHandler();
setToastStatus('success');
setToastMessage(t('toast.sync'));
setToastShow(true);
},
onError: (error) => {
console.log('Login Failed');
setToastStatus('error');
setToastMessage(error?.error_description || 'Error in authenticating!');
setToastShow(true);
},
scope: 'https://www.googleapis.com/auth/drive.file',
});
const logout = () => {
setGoogleAccessToken(undefined);
setSyncStatus('unauthenticated');
setCloudSync(false);
googleLogout();
useStore.persist.setOptions({
storage: createJSONStorage(() => localStorage),
});
useStore.persist.rehydrate();
setToastStatus('success');
setToastMessage(t('toast.stop'));
setToastShow(true);
};
return (
<div className='flex gap-4 flex-wrap justify-center'>
<button className='btn btn-primary' onClick={() => login()}>
{t('button.sync')}
</button>
{cloudSync && (
<button className='btn btn-neutral' onClick={logout}>
{t('button.stop')}
</button>
)}
</div>
);
};
export default GoogleSyncButton;

View file

@ -0,0 +1 @@
export { default } from './GoogleSync';

View file

@ -27,9 +27,9 @@ const ChatFolder = ({
folderChats: ChatHistoryInterface[]; folderChats: ChatHistoryInterface[];
folderId: string; folderId: string;
}) => { }) => {
const folderName = useStore((state) => state.folders[folderId].name); const folderName = useStore((state) => state.folders[folderId]?.name);
const isExpanded = useStore((state) => state.folders[folderId].expanded); const isExpanded = useStore((state) => state.folders[folderId]?.expanded);
const color = useStore((state) => state.folders[folderId].color); const color = useStore((state) => state.folders[folderId]?.color);
const setChats = useStore((state) => state.setChats); const setChats = useStore((state) => state.setChats);
const setFolders = useStore((state) => state.setFolders); const setFolders = useStore((state) => state.setFolders);

View file

@ -8,6 +8,9 @@ import AboutMenu from '@components/AboutMenu';
import ImportExportChat from '@components/ImportExportChat'; import ImportExportChat from '@components/ImportExportChat';
import SettingsMenu from '@components/SettingsMenu'; import SettingsMenu from '@components/SettingsMenu';
import CollapseOptions from './CollapseOptions'; import CollapseOptions from './CollapseOptions';
import GoogleSync from '@components/GoogleSync';
const googleClientId = import.meta.env.VITE_GOOGLE_CLIENT_ID || undefined;
const MenuOptions = () => { const MenuOptions = () => {
const hideMenuOptions = useStore((state) => state.hideMenuOptions); const hideMenuOptions = useStore((state) => state.hideMenuOptions);
@ -19,6 +22,7 @@ const MenuOptions = () => {
hideMenuOptions ? 'max-h-0' : 'max-h-full' hideMenuOptions ? 'max-h-0' : 'max-h-full'
} overflow-hidden transition-all`} } overflow-hidden transition-all`}
> >
{googleClientId && <GoogleSync clientId={googleClientId} />}
<AboutMenu /> <AboutMenu />
<ClearConversation /> <ClearConversation />
<ImportExportChat /> <ImportExportChat />

View file

@ -1,6 +1,7 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import useStore from '@store/store'; import useStore from '@store/store';
import useCloudAuthStore from '@store/cloud-auth-store';
import PopupModal from '@components/PopupModal'; import PopupModal from '@components/PopupModal';
import SettingIcon from '@icon/SettingIcon'; import SettingIcon from '@icon/SettingIcon';
@ -11,6 +12,7 @@ import PromptLibraryMenu from '@components/PromptLibraryMenu';
import ChatConfigMenu from '@components/ChatConfigMenu'; import ChatConfigMenu from '@components/ChatConfigMenu';
import EnterToSubmitToggle from './EnterToSubmitToggle'; import EnterToSubmitToggle from './EnterToSubmitToggle';
const SettingsMenu = () => { const SettingsMenu = () => {
const { t } = useTranslation(); const { t } = useTranslation();

View file

@ -0,0 +1,134 @@
import React, { useEffect, useState } from 'react';
import useStore from '@store/store';
export type ToastStatus = 'success' | 'error' | 'warning';
const Toast = () => {
const message = useStore((state) => state.toastMessage);
const status = useStore((state) => state.toastStatus);
const toastShow = useStore((state) => state.toastShow);
const setToastShow = useStore((state) => state.setToastShow);
const [timeoutID, setTimeoutID] = useState<number>();
useEffect(() => {
if (toastShow) {
window.clearTimeout(timeoutID);
const newTimeoutID = window.setTimeout(() => {
setToastShow(false);
}, 5000);
setTimeoutID(newTimeoutID);
}
}, [toastShow, status, message]);
return toastShow ? (
<div
className={`flex fixed right-5 bottom-5 z-[1000] items-center w-3/4 md:w-full max-w-xs p-4 mb-4 text-gray-500 dark:text-gray-400 rounded-lg shadow-md border border-gray-400/30 animate-bounce`}
role='alert'
>
<StatusIcon status={status} />
<div className='ml-3 text-sm font-normal'>{message}</div>
<button
type='button'
className='ml-auto -mx-1.5 -my-1.5 bg-white text-gray-400 hover:text-gray-900 rounded-lg focus:ring-2 focus:ring-gray-300 p-1.5 hover:bg-gray-100 inline-flex h-8 w-8 dark:text-gray-500 dark:hover:text-white dark:bg-gray-800 dark:hover:bg-gray-700'
aria-label='Close'
onClick={() => {
setToastShow(false);
}}
>
<CloseIcon />
</button>
</div>
) : (
<></>
);
};
const StatusIcon = ({ status }: { status: ToastStatus }) => {
const statusToIcon = {
success: <CheckIcon />,
error: <ErrorIcon />,
warning: <WarningIcon />,
};
return statusToIcon[status] || null;
};
const CloseIcon = () => (
<>
<span className='sr-only'>Close</span>
<svg
aria-hidden='true'
className='w-5 h-5'
fill='currentColor'
viewBox='0 0 20 20'
xmlns='http://www.w3.org/2000/svg'
>
<path
fillRule='evenodd'
d='M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z'
clipRule='evenodd'
></path>
</svg>
</>
);
const CheckIcon = () => (
<div className='inline-flex items-center justify-center flex-shrink-0 w-8 h-8 text-green-500 bg-green-100 rounded-lg dark:bg-green-800 dark:text-green-200'>
<svg
aria-hidden='true'
className='w-5 h-5'
fill='currentColor'
viewBox='0 0 20 20'
xmlns='http://www.w3.org/2000/svg'
>
<path
fillRule='evenodd'
d='M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z'
clipRule='evenodd'
></path>
</svg>
<span className='sr-only'>Check icon</span>
</div>
);
const ErrorIcon = () => (
<div className='inline-flex items-center justify-center flex-shrink-0 w-8 h-8 text-red-500 bg-red-100 rounded-lg dark:bg-red-800 dark:text-red-200'>
<svg
aria-hidden='true'
className='w-5 h-5'
fill='currentColor'
viewBox='0 0 20 20'
xmlns='http://www.w3.org/2000/svg'
>
<path
fillRule='evenodd'
d='M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z'
clipRule='evenodd'
></path>
</svg>
<span className='sr-only'>Error icon</span>
</div>
);
const WarningIcon = () => (
<div className='inline-flex items-center justify-center flex-shrink-0 w-8 h-8 text-orange-500 bg-orange-100 rounded-lg dark:bg-orange-700 dark:text-orange-200'>
<svg
aria-hidden='true'
className='w-5 h-5'
fill='currentColor'
viewBox='0 0 20 20'
xmlns='http://www.w3.org/2000/svg'
>
<path
fillRule='evenodd'
d='M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z'
clipRule='evenodd'
></path>
</svg>
<span className='sr-only'>Warning icon</span>
</div>
);
export default Toast;

View file

@ -0,0 +1 @@
export { default } from './Toast';

View file

@ -4,6 +4,32 @@ import { initReactI18next } from 'react-i18next';
import Backend from 'i18next-http-backend'; import Backend from 'i18next-http-backend';
import LanguageDetector from 'i18next-browser-languagedetector'; import LanguageDetector from 'i18next-browser-languagedetector';
const googleClientId = import.meta.env.VITE_GOOGLE_CLIENT_ID || undefined;
export const i18nLanguages = [
// 'ar',
'da',
'en',
'en-GB',
'en-US',
'es',
'fr',
'fr-FR',
'it',
'ja',
'ms',
'nb',
'sv',
// 'ug',
'yue',
'zh-CN',
'zh-HK',
'zh-TW',
];
const namespace = ['main', 'api', 'about', 'model'];
if (googleClientId) namespace.push('drive');
i18n i18n
.use(Backend) .use(Backend)
.use(LanguageDetector) .use(LanguageDetector)
@ -15,7 +41,7 @@ i18n
fallbackLng: { fallbackLng: {
default: ['en'], default: ['en'],
}, },
ns: ['main', 'api', 'about', 'model'], ns: namespace,
defaultNS: 'main', defaultNS: 'main',
}); });

View file

@ -0,0 +1,50 @@
import { SyncStatus } from '@type/google-api';
import { StoreSlice } from './cloud-auth-store';
export interface CloudAuthSlice {
googleAccessToken?: string;
googleRefreshToken?: string;
cloudSync: boolean;
syncStatus: SyncStatus;
fileId?: string;
setGoogleAccessToken: (googleAccessToken?: string) => void;
setGoogleRefreshToken: (googleRefreshToken?: string) => void;
setFileId: (fileId?: string) => void;
setCloudSync: (cloudSync: boolean) => void;
setSyncStatus: (syncStatus: SyncStatus) => void;
}
export const createCloudAuthSlice: StoreSlice<CloudAuthSlice> = (set, get) => ({
cloudSync: false,
syncStatus: 'unauthenticated',
setGoogleAccessToken: (googleAccessToken?: string) => {
set((prev: CloudAuthSlice) => ({
...prev,
googleAccessToken: googleAccessToken,
}));
},
setGoogleRefreshToken: (googleRefreshToken?: string) => {
set((prev: CloudAuthSlice) => ({
...prev,
googleRefreshToken: googleRefreshToken,
}));
},
setFileId: (fileId?: string) => {
set((prev: CloudAuthSlice) => ({
...prev,
fileId: fileId,
}));
},
setCloudSync: (cloudSync: boolean) => {
set((prev: CloudAuthSlice) => ({
...prev,
cloudSync: cloudSync,
}));
},
setSyncStatus: (syncStatus: SyncStatus) => {
set((prev: CloudAuthSlice) => ({
...prev,
syncStatus: syncStatus,
}));
},
});

View file

@ -0,0 +1,28 @@
import { StoreApi, create } from 'zustand';
import { persist } from 'zustand/middleware';
import { CloudAuthSlice, createCloudAuthSlice } from './cloud-auth-slice';
export type StoreState = CloudAuthSlice;
export type StoreSlice<T> = (
set: StoreApi<StoreState>['setState'],
get: StoreApi<StoreState>['getState']
) => T;
const useCloudAuthStore = create<StoreState>()(
persist(
(set, get) => ({
...createCloudAuthSlice(set, get),
}),
{
name: 'cloud',
partialize: (state) => ({
cloudSync: state.cloudSync,
fileId: state.fileId,
}),
version: 1,
}
)
);
export default useCloudAuthStore;

View file

@ -0,0 +1,72 @@
import { PersistStorage, StorageValue, StateStorage } from 'zustand/middleware';
import useCloudAuthStore from '@store/cloud-auth-store';
import useStore from '@store/store';
import {
deleteDriveFile,
getDriveFile,
updateDriveFileDebounced,
validateGoogleOath2AccessToken,
} from '@api/google-api';
const createGoogleCloudStorage = <S>(): PersistStorage<S> | undefined => {
const accessToken = useCloudAuthStore.getState().googleAccessToken;
const fileId = useCloudAuthStore.getState().fileId;
if (!accessToken || !fileId) return;
try {
const authenticated = validateGoogleOath2AccessToken(accessToken);
if (!authenticated) return;
} catch (e) {
// prevent error if the storage is not defined (e.g. when server side rendering a page)
return;
}
const persistStorage: PersistStorage<S> = {
getItem: async (name) => {
useCloudAuthStore.getState().setSyncStatus('syncing');
try {
const accessToken = useCloudAuthStore.getState().googleAccessToken;
const fileId = useCloudAuthStore.getState().fileId;
if (!accessToken || !fileId) return null;
const data: StorageValue<S> = await getDriveFile(fileId, accessToken);
useCloudAuthStore.getState().setSyncStatus('synced');
return data;
} catch (e: unknown) {
useCloudAuthStore.getState().setSyncStatus('unauthenticated');
useStore.getState().setToastMessage((e as Error).message);
useStore.getState().setToastShow(true);
useStore.getState().setToastStatus('error');
return null;
}
},
setItem: async (name, newValue): Promise<void> => {
const accessToken = useCloudAuthStore.getState().googleAccessToken;
const fileId = useCloudAuthStore.getState().fileId;
if (!accessToken || !fileId) return;
const blob = new Blob([JSON.stringify(newValue)], {
type: 'application/json',
});
const file = new File([blob], 'better-chatgpt.json', {
type: 'application/json',
});
if (useCloudAuthStore.getState().syncStatus !== 'unauthenticated') {
useCloudAuthStore.getState().setSyncStatus('syncing');
await updateDriveFileDebounced(file, fileId, accessToken);
}
},
removeItem: async (name): Promise<void> => {
const accessToken = useCloudAuthStore.getState().googleAccessToken;
const fileId = useCloudAuthStore.getState().fileId;
if (!accessToken || !fileId) return;
await deleteDriveFile(accessToken, fileId);
},
};
return persistStorage;
};
export default createGoogleCloudStorage;

View file

@ -5,6 +5,7 @@ import { InputSlice, createInputSlice } from './input-slice';
import { AuthSlice, createAuthSlice } from './auth-slice'; import { AuthSlice, createAuthSlice } from './auth-slice';
import { ConfigSlice, createConfigSlice } from './config-slice'; import { ConfigSlice, createConfigSlice } from './config-slice';
import { PromptSlice, createPromptSlice } from './prompt-slice'; import { PromptSlice, createPromptSlice } from './prompt-slice';
import { ToastSlice, createToastSlice } from './toast-slice';
import { import {
LocalStorageInterfaceV0ToV1, LocalStorageInterfaceV0ToV1,
LocalStorageInterfaceV1ToV2, LocalStorageInterfaceV1ToV2,
@ -30,13 +31,31 @@ export type StoreState = ChatSlice &
InputSlice & InputSlice &
AuthSlice & AuthSlice &
ConfigSlice & ConfigSlice &
PromptSlice; PromptSlice &
ToastSlice;
export type StoreSlice<T> = ( export type StoreSlice<T> = (
set: StoreApi<StoreState>['setState'], set: StoreApi<StoreState>['setState'],
get: StoreApi<StoreState>['getState'] get: StoreApi<StoreState>['getState']
) => T; ) => T;
export const createPartializedState = (state: StoreState) => ({
chats: state.chats,
currentChatIndex: state.currentChatIndex,
apiKey: state.apiKey,
apiEndpoint: state.apiEndpoint,
theme: state.theme,
autoTitle: state.autoTitle,
prompts: state.prompts,
defaultChatConfig: state.defaultChatConfig,
defaultSystemMessage: state.defaultSystemMessage,
hideMenuOptions: state.hideMenuOptions,
firstVisit: state.firstVisit,
hideSideMenu: state.hideSideMenu,
folders: state.folders,
enterToSubmit: state.enterToSubmit,
});
const useStore = create<StoreState>()( const useStore = create<StoreState>()(
persist( persist(
(set, get) => ({ (set, get) => ({
@ -45,25 +64,11 @@ const useStore = create<StoreState>()(
...createAuthSlice(set, get), ...createAuthSlice(set, get),
...createConfigSlice(set, get), ...createConfigSlice(set, get),
...createPromptSlice(set, get), ...createPromptSlice(set, get),
...createToastSlice(set, get),
}), }),
{ {
name: 'free-chat-gpt', name: 'free-chat-gpt',
partialize: (state) => ({ partialize: (state) => createPartializedState(state),
chats: state.chats,
currentChatIndex: state.currentChatIndex,
apiKey: state.apiKey,
apiEndpoint: state.apiEndpoint,
theme: state.theme,
autoTitle: state.autoTitle,
prompts: state.prompts,
defaultChatConfig: state.defaultChatConfig,
defaultSystemMessage: state.defaultSystemMessage,
hideMenuOptions: state.hideMenuOptions,
firstVisit: state.firstVisit,
hideSideMenu: state.hideSideMenu,
folders: state.folders,
enterToSubmit: state.enterToSubmit,
}),
version: 8, version: 8,
migrate: (persistedState, version) => { migrate: (persistedState, version) => {
switch (version) { switch (version) {

26
src/store/toast-slice.ts Normal file
View file

@ -0,0 +1,26 @@
import { ToastStatus } from '@components/Toast/Toast';
import { StoreSlice } from './store';
export interface ToastSlice {
toastShow: boolean;
toastMessage: string;
toastStatus: ToastStatus;
setToastShow: (toastShow: boolean) => void;
setToastMessage: (toastMessage: string) => void;
setToastStatus: (toastStatus: ToastStatus) => void;
}
export const createToastSlice: StoreSlice<ToastSlice> = (set, get) => ({
toastShow: false,
toastMessage: '',
toastStatus: 'success',
setToastShow: (toastShow: boolean) => {
set((prev) => ({ ...prev, toastShow }));
},
setToastMessage: (toastMessage: string) => {
set((prev: ToastSlice) => ({ ...prev, toastMessage }));
},
setToastStatus: (toastStatus: ToastStatus) => {
set((prev: ToastSlice) => ({ ...prev, toastStatus }));
},
});

27
src/types/google-api.ts Normal file
View file

@ -0,0 +1,27 @@
export interface GoogleFileResource {
kind: string;
id: string;
name: string;
mimeType: string;
}
export interface GoogleTokenInfo {
azp: string;
aud: string;
sub: string;
scope: string;
exp: string;
expires_in: string;
email: string;
email_verified: string;
access_type: string;
}
export interface GoogleFileList {
nextPageToken?: string;
kind: string;
incompleteSearch: boolean;
files: GoogleFileResource[];
}
export type SyncStatus = 'unauthenticated' | 'syncing' | 'synced';

5
src/types/persist.ts Normal file
View file

@ -0,0 +1,5 @@
import { LocalStorageInterfaceV7oV8 } from './chat';
interface PersistStorageState extends LocalStorageInterfaceV7oV8 {}
export default PersistStorageState;

38
src/utils/google-api.ts Normal file
View file

@ -0,0 +1,38 @@
import { listDriveFiles } from '@api/google-api';
import useStore, { createPartializedState } from '@store/store';
import useCloudAuthStore from '@store/cloud-auth-store';
export const getFiles = async (googleAccessToken: string) => {
try {
const driveFiles = await listDriveFiles(googleAccessToken);
return driveFiles.files;
} catch (e: unknown) {
useCloudAuthStore.getState().setSyncStatus('unauthenticated');
useStore.getState().setToastMessage((e as Error).message);
useStore.getState().setToastShow(true);
useStore.getState().setToastStatus('error');
return;
}
};
export const getFileID = async (
googleAccessToken: string
): Promise<string | null> => {
const driveFiles = await listDriveFiles(googleAccessToken);
if (driveFiles.files.length === 0) return null;
return driveFiles.files[0].id;
};
export const stateToFile = () => {
const partializedState = createPartializedState(useStore.getState());
const blob = new Blob([JSON.stringify(partializedState)], {
type: 'application/json',
});
const file = new File([blob], 'better-chatgpt.json', {
type: 'application/json',
});
return file;
};

View file

@ -345,6 +345,11 @@
"@nodelib/fs.scandir" "2.1.5" "@nodelib/fs.scandir" "2.1.5"
fastq "^1.6.0" fastq "^1.6.0"
"@react-oauth/google@^0.9.0":
version "0.9.0"
resolved "https://registry.yarnpkg.com/@react-oauth/google/-/google-0.9.0.tgz#af65ee0c6238a0988056d9e820029f99255f8381"
integrity sha512-iq9I6A4uwZezU/BixqLM6UET6an559ufC4Nh0lEIeIaKC3TJRvcPNWCjjHny56yAhgdT6ivUicLIvEoiSMjnmg==
"@rollup/plugin-virtual@^3.0.1": "@rollup/plugin-virtual@^3.0.1":
version "3.0.1" version "3.0.1"
resolved "https://registry.yarnpkg.com/@rollup/plugin-virtual/-/plugin-virtual-3.0.1.tgz#cea7e489481cc0ca91516c047f8c53c1cfb1adf6" resolved "https://registry.yarnpkg.com/@rollup/plugin-virtual/-/plugin-virtual-3.0.1.tgz#cea7e489481cc0ca91516c047f8c53c1cfb1adf6"