Merge branch 'main' into persian

This commit is contained in:
Jing Hua
2023-05-17 23:46:14 +08:00
committed by GitHub
113 changed files with 3178 additions and 1016 deletions

View File

@@ -2,4 +2,5 @@
VITE_CUSTOM_API_ENDPOINT=
VITE_DEFAULT_API_ENDPOINT=
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

@@ -11,7 +11,7 @@ permissions:
id-token: write
concurrency:
group: "pages"
group: 'pages'
cancel-in-progress: true
jobs:
@@ -33,12 +33,14 @@ jobs:
- name: Build website
run: yarn build
env:
VITE_GOOGLE_CLIENT_ID: ${{ secrets.GCLIENT }}
- name: Upload artifact
uses: actions/upload-pages-artifact@v1
with:
path: './dist'
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v1
uses: actions/deploy-pages@v1

View File

@@ -27,3 +27,4 @@ jobs:
run: yarn make
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
VITE_GOOGLE_CLIENT_ID: ${{ secrets.GCLIENT }}

View File

@@ -54,7 +54,8 @@ Better ChatGPT 已经包含了大量的功能。您可以使用以下功能:
- 支持使用内置代理解決 ChatGPT 地区限制
- 支持自定义提示词资料库
- 支持使用文件夹整理聊天
- 支持使用文件夹(且带颜色)整理聊天
- 支持筛选聊天和文件夹
- 支持实时计算 token 数量和价格
- 支持使用 ShareGPT 分享聊天
- 支持自定义 API 参数(例如存在惩罚)
@@ -64,6 +65,8 @@ Better ChatGPT 已经包含了大量的功能。您可以使用以下功能:
- 支持自动保存聊天记录
- 支持导入/导出聊天记录
- 支持将聊天保存为 Markdown/图片/JSON
- 支持与 Google Drive 同步
- 支持 Azure OpenAI 终端
- 支持多语言 (i18n)
# 🛠️ 使用方法
@@ -157,10 +160,11 @@ Better ChatGPT 已经包含了大量的功能。您可以使用以下功能:
如果您想支持我们的团队,请考虑通过以下方法之一赞助我们。每一份贡献,无论多小,都有助于我们维护和改善我们的服务。
| 付款方式 | 链接 |
| -------------- | ---------------------------------------------------------------------------------------- |
| 支付宝 (Ayaka) | <img src="https://ayaka14732.github.io/sponsor/alipay.jpg" width=150 /> |
| 微信 (Ayaka) | <img src="https://ayaka14732.github.io/sponsor/wechat.png" width=150 /> |
| KoFi | [![support](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/betterchatgpt) |
| 付款方式 | 链接 |
| -------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
| 支付宝 (Ayaka) | <img src="https://ayaka14732.github.io/sponsor/alipay.jpg" width=150 /> |
| 微信 (Ayaka) | <img src="https://ayaka14732.github.io/sponsor/wechat.png" width=150 /> |
| GitHub | [![GitHub Sponsor](https://img.shields.io/static/v1?label=Sponsor&message=%E2%9D%A4&logo=GitHub&color=%23fe8e86)](https://github.com/sponsors/ztjhz) |
| KoFi | [![support](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/betterchatgpt) |
感谢您成为我们社区的一员,我们期待着在未来为您提供更好的服务。

View File

@@ -61,7 +61,8 @@ Better ChatGPT comes with a bundle of amazing features! Here are some of them:
- Proxy to bypass ChatGPT regional restrictions
- Prompt library
- Organize chats into folders
- Organize chats into folders (with colours)
- Filter chats and folders
- Token count and pricing
- ShareGPT integration
- Custom model parameters (e.g. presence_penalty)
@@ -71,6 +72,8 @@ Better ChatGPT comes with a bundle of amazing features! Here are some of them:
- Save chat automatically to local storage
- Import / Export chat
- Download chat (markdown / image / json)
- Sync to Google Drive
- Azure OpenAI endpoint support
- Multiple language support (i18n)
# 🛠️ Usage
@@ -164,10 +167,11 @@ If you have enjoyed using our app, we kindly ask you to give this project a ⭐
If you would like to support the team, consider sponsoring us through one of the methods below. Every contribution, no matter how small, helps us to maintain and improve our service.
| Payment Method | Link |
| -------------- | ---------------------------------------------------------------------------------------- |
| KoFi | [![support](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/betterchatgpt) |
| Alipay (Ayaka) | <img src="https://ayaka14732.github.io/sponsor/alipay.jpg" width=150 /> |
| Wechat (Ayaka) | <img src="https://ayaka14732.github.io/sponsor/wechat.png" width=150 /> |
| Payment Method | Link |
| -------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
| GitHub | [![GitHub Sponsor](https://img.shields.io/static/v1?label=Sponsor&message=%E2%9D%A4&logo=GitHub&color=%23fe8e86)](https://github.com/sponsors/ztjhz) |
| KoFi | [![support](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/betterchatgpt) |
| Alipay (Ayaka) | <img src="https://ayaka14732.github.io/sponsor/alipay.jpg" width=150 /> |
| Wechat (Ayaka) | <img src="https://ayaka14732.github.io/sponsor/wechat.png" width=150 /> |
Thank you for being a part of our community, and we look forward to serving you better in the future.

View File

@@ -1,39 +1,79 @@
const path = require('path');
const { app, BrowserWindow } = require('electron');
const {dialog, app, BrowserWindow, Tray, Menu } = require('electron');
const isDev = require('electron-is-dev');
const { autoUpdater } = require('electron-updater');
let win = null;
const instanceLock = app.requestSingleInstanceLock();
if (require('electron-squirrel-startup')) app.quit();
const PORT = isDev ? '5173' : '51735';
function createWindow() {
let iconPath = '';
if (isDev) {
iconPath = path.join(__dirname, '../public/favicon-516x516.png');
iconPath = path.join(__dirname, '../public/icon-rounded.png');
} else {
iconPath = path.join(__dirname, '../dist/favicon-516x516.png');
iconPath = path.join(__dirname, '../dist/icon-rounded.png');
}
autoUpdater.checkForUpdatesAndNotify();
const win = new BrowserWindow({
win = new BrowserWindow({
autoHideMenuBar: true,
show: false,
icon: iconPath,
});
createTray(win);
win.maximize();
win.show();
win.loadURL(
isDev
? 'http://localhost:5173'
: `file://${path.join(__dirname, '../dist/index.html')}`
);
isDev || createServer();
win.loadURL(`http://localhost:${PORT}`);
if (isDev) {
win.webContents.openDevTools({ mode: 'detach' });
}
return win;
}
app.whenReady().then(createWindow);
const createTray = (window) => {
const tray = new Tray(
path.join(
__dirname,
isDev ? '../public/icon-rounded.png' : '../dist/icon-rounded.png'
)
);
const contextMenu = Menu.buildFromTemplate([
{
label: 'Show',
click: () => {
win.maximize();
window.show();
},
},
{
label: 'Exit',
click: () => {
app.isQuiting = true;
app.quit();
},
},
]);
tray.on('click', () => {
win.maximize();
window.show();
});
tray.setToolTip('Better ChatGPT');
tray.setContextMenu(contextMenu);
return tray;
};
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
@@ -41,8 +81,91 @@ app.on('window-all-closed', () => {
}
});
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) {
createWindow();
}
process.on('uncaughtException', (error) => {
// Perform any necessary cleanup tasks here
dialog.showErrorBox('An error occurred', error.stack);
// Exit the app
process.exit(1);
});
if (!instanceLock) {
app.quit()
} else {
app.on('second-instance', (event, commandLine, workingDirectory) => {
if (win) {
if (win.isMinimized()) win.restore()
win.focus()
}
})
app.whenReady().then(() => {
win = 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 === '/'
? `${path.join(__dirname, '../dist/index.html')}`
: `${path.join(__dirname, `../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

@@ -1,7 +1,7 @@
{
"name": "better-chatgpt",
"private": true,
"version": "1.0.2",
"version": "1.0.3",
"type": "module",
"homepage": "./",
"main": "electron/index.cjs",
@@ -24,7 +24,10 @@
},
"dmg": {
"title": "${productName} ${version}",
"icon": "dist/favicon-516x516.png"
"icon": "dist/icon-rounded.png"
},
"mac": {
"icon": "dist/icon-rounded.png"
},
"linux": {
"target": [
@@ -32,15 +35,16 @@
"AppImage"
],
"category": "Chat",
"icon": "dist/favicon-516x516.png"
"icon": "dist/icon-rounded.png"
},
"win": {
"target": "NSIS",
"icon": "dist/favicon-516x516.png"
"icon": "dist/icon-rounded.png"
}
},
"dependencies": {
"@dqbd/tiktoken": "^1.0.2",
"@react-oauth/google": "^0.9.0",
"electron-is-dev": "^2.0.0",
"electron-squirrel-startup": "^1.0.0",
"electron-updater": "^5.3.0",
@@ -49,6 +53,7 @@
"i18next-browser-languagedetector": "^7.0.1",
"i18next-http-backend": "^2.1.1",
"jspdf": "^2.5.1",
"katex": "^0.16.4",
"lodash": "^4.17.21",
"match-sorter": "^6.3.1",
"papaparse": "^5.4.1",

BIN
public/icon-rounded.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

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

@@ -24,11 +24,17 @@
"setting": "Indstillinger",
"image": "Billede",
"autoTitle": "Auto generer titel",
"advancedMode": "Avanceret tilstand",
"inlineLatex": "Indlejret LaTeX",
"prompt": "Opgave",
"promptLibrary": "Opgavebibliotek",
"name": "Navn",
"search": "Søg",
"total": "Total",
"resetCost": "Nulstil Omkostninger",
"countTotalTokens": "Tæl totale tokens",
"morePrompts": "Du kan finde flere opgaver her: ",
"clearPrompts": "Ryd prompter",
"postOnShareGPT": {
"title": "Indlæg på ShareGPT",
"warning": "Vær opmærksom på, at ved at poste din samtale på ShareGPT, vil den blive offentligt tilgængelig og synlig for alle. Når den er postet, kan samtalen ikke skjules eller slettes og kan blive arkiveret eller delt af andre. Vi råder dig til at overveje nøje og undgå at dele følsomme eller private oplysninger på denne platform."
@@ -37,5 +43,5 @@
"cloneChat": "Klon Chat",
"cloned": "Klonet",
"enterToSubmit": "Tryk Enter for at sende",
"submitPlaceholder": "Type a message or select [/] for a predefined prompt..."
"submitPlaceholder": "Skriv en besked eller klik på [/] for opgave..."
}

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

@@ -6,7 +6,7 @@
"warning": "Warning",
"clearMessageWarning": "Please be advised that by submitting this message, all subsequent messages will be deleted!",
"clearConversationWarning": "Please be advised that by confirming this action, all messages will be deleted!",
"clearConversation": "Clear Conversation",
"clearConversation": "Clear Conversation History",
"import": "Import",
"export": "Export",
"author": "Made by Jing Hua",
@@ -24,11 +24,17 @@
"setting": "Settings",
"image": "Image",
"autoTitle": "Auto generate title",
"advancedMode": "Advanced mode",
"inlineLatex": "Inline Latex",
"prompt": "Prompt",
"promptLibrary": "Prompt Library",
"name": "Name",
"search": "Search",
"total": "Total",
"resetCost": "Reset Costs",
"countTotalTokens": "Count total tokens",
"morePrompts": "You can find more prompts here: ",
"clearPrompts": "Clear prompts",
"postOnShareGPT": {
"title": "Post on ShareGPT",
"warning": "Please be aware that by posting your conversation on ShareGPT, it will become publicly accessible and viewable to anyone. Once posted, the conversation cannot be hidden or deleted, and may be archived or shared by others. We advise you to consider carefully and avoid sharing sensitive or private information on this platform."
@@ -37,5 +43,5 @@
"cloneChat": "Clone Chat",
"cloned": "Cloned",
"enterToSubmit": "Enter to submit",
"submitPlaceholder": "Type a message or select [/] for a predefined prompt..."
"submitPlaceholder": "Type a message or click [/] for prompts..."
}

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

@@ -6,7 +6,7 @@
"warning": "Warning",
"clearMessageWarning": "Please be advised that by submitting this message, all subsequent messages will be deleted!",
"clearConversationWarning": "Please be advised that by confirming this action, all messages will be deleted!",
"clearConversation": "Clear Conversation",
"clearConversation": "Clear Conversation History",
"import": "Import",
"export": "Export",
"author": "Made by Jing Hua",
@@ -24,11 +24,17 @@
"setting": "Settings",
"image": "Image",
"autoTitle": "Auto generate title",
"advancedMode": "Advanced mode",
"inlineLatex": "Inline Latex",
"prompt": "Prompt",
"promptLibrary": "Prompt Library",
"name": "Name",
"search": "Search",
"total": "Total",
"resetCost": "Reset Costs",
"countTotalTokens": "Count total tokens",
"morePrompts": "You can find more prompts here: ",
"clearPrompts": "Clear prompts",
"postOnShareGPT": {
"title": "Post on ShareGPT",
"warning": "Please be aware that by posting your conversation on ShareGPT, it will become publicly accessible and viewable to anyone. Once posted, the conversation cannot be hidden or deleted, and may be archived or shared by others. We advise you to consider carefully and avoid sharing sensitive or private information on this platform."
@@ -37,5 +43,5 @@
"cloneChat": "Clone Chat",
"cloned": "Cloned",
"enterToSubmit": "Enter to submit",
"submitPlaceholder": "Type a message or select [/] for a predefined prompt..."
"submitPlaceholder": "Type a message or click [/] for prompts..."
}

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

@@ -24,11 +24,17 @@
"setting": "Ajustes",
"image": "Imagen",
"autoTitle": "Generar automáticamente el título de la conversación.",
"advancedMode": "Modo avanzado",
"inlineLatex": "Latex en línea",
"prompt": "Prompt",
"promptLibrary": "Librería de Prompts",
"name": "Nombre",
"search": "Buscar",
"total": "Total",
"resetCost": "Reiniciar costos",
"countTotalTokens": "Contar tokens totales",
"morePrompts": "Puedes encontrar más prompts aquí: ",
"clearPrompts": "Prompts claras",
"postOnShareGPT": {
"title": "Publicar en ShareGPT",
"warning": "Por favor, tenga en cuenta que al publicar su conversación en ShareGPT, esta será accesible y visible para cualquiera. Una vez publicada, la conversación no se podrá ocultar ni eliminar, y puede ser archivada o compartida por otros. Le aconsejamos que lo considere detenidamente y evite compartir información sensible o privada en esta plataforma."
@@ -37,5 +43,5 @@
"cloneChat": "Clone Chat",
"cloned": "Cloned",
"enterToSubmit": "Enter to submit",
"submitPlaceholder": "Type a message or select [/] for a predefined prompt..."
"submitPlaceholder": "Escribe un mensaje o haz clic en [/] para prompt..."
}

View File

@@ -0,0 +1,16 @@
{
"name": "Google Sync",
"tagline": "Synchronisez vos discussions et paramètres sans effort avec Google Drive.",
"button": {
"sync": "Synchroniser vos discussions",
"stop": "Arrêter la synchronisation",
"create": "Créer un nouveau fichier",
"confirm": "Confirmer la sélection"
},
"notice": "Note: Vous devrez vous reconnecter à chaque visite ou toutes les heures. Pour éviter que vos données cloud soient écrasées, n'utilisez pas BetterChatGPT sur plus d'un appareil en même temps.",
"privacy": "Votre vie privée est importante pour nous et pour la garantir, BetterChatGPT n'a que des accès non sensibles, c'est-à-dire qu'il peut seulement créer, visualiser et gérer ses propres fichiers et dossiers.",
"toast": {
"sync": "Synchronisation réussie!",
"stop": "Synchronisation arrêtée"
}
}

View File

@@ -24,11 +24,17 @@
"setting": "Paramètres",
"image": "Image",
"autoTitle": "Générer le titre automatiquement",
"advancedMode": "Mode avancé",
"inlineLatex": "Latex en ligne",
"prompt": "Incitation",
"promptLibrary": "Bibliothèque de prompt",
"name": "Nom",
"search": "Recherche",
"total": "Total",
"resetCost": "Réinitialiser les coûts",
"countTotalTokens": "Compter le nombre total de jetons",
"morePrompts": "Vous pouvez trouver plus de prompts ici : ",
"clearPrompts": "Effacer les prompts",
"postOnShareGPT": {
"title": "Publier sur ShareGPT",
"warning": "Veuillez noter que si vous publiez votre conversation sur ShareGPT, elle deviendra accessible au public et visible par tous. Une fois publiée, la conversation ne peut pas être cachée ou supprimée, et peut être archivée ou partagée par d'autres. Nous vous conseillons de considérer attentivement et d'éviter de partager des informations sensibles ou privées sur cette plateforme."
@@ -37,5 +43,5 @@
"cloneChat": "Cloner la Conversation",
"cloned": "Clonée",
"enterToSubmit": "Entrée pour soumettre",
"submitPlaceholder": "Saisissez un message ou sélectionnez [/] pour un prompt prédéfini..."
"submitPlaceholder": "Saisissez un message ou cliquez sur [/] pour des prompts..."
}

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

@@ -24,11 +24,17 @@
"setting": "Impostazioni",
"image": "Immagine",
"autoTitle": "Genera automaticamente il titolo",
"advancedMode": "Modalità avanzata",
"inlineLatex": "Latex in linea",
"prompt": "Prompt",
"promptLibrary": "Libreria Prompt",
"name": "Nome",
"search": "Cerca",
"total": "Totale",
"resetCost": "Ripristina costi",
"countTotalTokens": "Conteggio totale dei token",
"morePrompts": "Puoi trovare altri prompt qui:",
"clearPrompts": "Cancella prompts",
"postOnShareGPT": {
"title": "Pubblica su ShareGPT",
"warning": "Ti ricordiamo che pubblicando la tua conversazione su ShareGPT, questa diventerà pubblicamente accessibile e visualizzabile da chiunque. Una volta pubblicata, la conversazione non può essere nascosta o cancellata e può essere archiviata o condivisa da altri. Ti consigliamo di valutare attentamente e di evitare di condividere informazioni sensibili o private su questa piattaforma."
@@ -37,5 +43,5 @@
"cloneChat": "Duplica Conversazione",
"cloned": "Duplicata",
"enterToSubmit": "Invio per inviare",
"submitPlaceholder": "Type a message or select [/] for a predefined prompt..."
"submitPlaceholder": "Digita un messaggio o fai clic su [/] per prompt..."
}

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

@@ -24,11 +24,17 @@
"setting": "設定",
"image": "画像",
"autoTitle": "タイトルを自動生成",
"advancedMode": "上級モード",
"inlineLatex": "インライン LaTeX",
"prompt": "プロンプト",
"promptLibrary": "プロンプトライブラリ",
"name": "名前",
"search": "検索",
"total": "合計",
"resetCost": "コストをリセットする",
"countTotalTokens": "トークンの合計数をカウント",
"morePrompts": "ここでさらにプロンプトを見つけることができます:",
"clearPrompts": "プロンプトをクリア",
"postOnShareGPT": {
"title": "ShareGPTに投稿",
"warning": "ShareGPTに会話を投稿すると、誰でもアクセスして閲覧できるようになることに注意してください。一度投稿すると、会話は非表示にできず、削除もできません。また、他の人がアーカイブや共有する可能性があります。このプラットフォームで機密性のある情報や個人情報を共有しないように注意してください。"
@@ -37,5 +43,5 @@
"cloneChat": "チャットのコピーを作成",
"cloned": "完了しました",
"enterToSubmit": "Enterキーを押して送信",
"submitPlaceholder": "Type a message or select [/] for a predefined prompt..."
"submitPlaceholder": "メッセージを入力するか、[/] をクリックしてプロンプトを表示します..."
}

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

@@ -24,11 +24,17 @@
"setting": "Tetapan",
"image": "Imej",
"autoTitle": "Jana tajuk secara automatik",
"advancedMode": "Mod lanjutan",
"inlineLatex": "Latex Sebaris",
"prompt": "Arahan",
"promptLibrary": "Pustaka Arahan",
"name": "Nama",
"search": "Cari",
"total": "Jumlah",
"resetCost": "Reset Kos",
"countTotalTokens": "Kira jumlah token keseluruhan",
"morePrompts": "Anda boleh mencari lebih banyak arahan di sini: ",
"clearPrompts": "Kosongkan arahan",
"postOnShareGPT": {
"title": "Siarkan di ShareGPT",
"warning": "Sila ambil perhatian bahawa dengan menyiarkan perbualan anda di ShareGPT, ia akan menjadi boleh diakses dan dilihat oleh sesiapa sahaja. Setelah disiarkan, perbualan tidak boleh disembunyikan atau dipadam, dan mungkin diarkibkan atau dikongsi oleh orang lain. Kami menasihatkan anda untuk mempertimbangkan dengan teliti dan mengelakkan berkongsi maklumat sensitif atau peribadi di platform ini."
@@ -37,5 +43,5 @@
"cloneChat": "Buat salinan perbualan ini",
"cloned": "Dicipta",
"enterToSubmit": "Tekan Enter untuk hantar",
"submitPlaceholder": "Type a message or select [/] for a predefined prompt..."
"submitPlaceholder": "Taip mesej atau klik [/] untuk arahan..."
}

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

@@ -24,11 +24,17 @@
"setting": "Innstillinger",
"image": "Bilde",
"autoTitle": "Auto generer tittel",
"advancedMode": "Avansert modus",
"inlineLatex": "Inline Latex",
"prompt": "Oppgave",
"promptLibrary": "Oppgavebibliotek",
"name": "Navn",
"search": "Søk",
"total": "Totalt",
"resetCost": "Tilbakestill kostnader",
"countTotalTokens": "Tell totale token",
"morePrompts": "Du kan finne flere oppgaver her: ",
"clearPrompts": "Tøm oppgave",
"postOnShareGPT": {
"title": "Innlegg på ShareGPT",
"warning": "Vær oppmerksom på at ved å poste samtalen din på ShareGPT, vil den bli offentlig tilgjengelig og synlig for alle. Når den er postet, kan samtalen ikke skjules eller slettes, og den kan bli arkivert eller delt av andre. Vi anbefaler deg å tenke nøye gjennom og unngå å dele sensitiv eller privat informasjon på denne plattformen."
@@ -37,5 +43,5 @@
"cloneChat": "Klone chat",
"cloned": "Klonet",
"enterToSubmit": "Trykk enter for å sende",
"submitPlaceholder": "Type a message or select [/] for a predefined prompt..."
"submitPlaceholder": "Skriv en melding eller klikk på [/] for oppgave..."
}

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

@@ -24,11 +24,17 @@
"setting": "Inställningar",
"image": "Bild",
"autoTitle": "Auto generera titel",
"advancedMode": "Avancerat läge",
"inlineLatex": "Inline Latex",
"prompt": "Uppmaning",
"promptLibrary": "Uppmaningsbibliotek",
"name": "Namn",
"search": "Sök",
"total": "Total",
"resetCost": "Återställ kostnader",
"countTotalTokens": "Räkna totala token",
"morePrompts": "Du kan hitta fler uppmaningar här: ",
"clearPrompts": "Rensa uppmaningar",
"postOnShareGPT": {
"title": "Inlägg på ShareGPT",
"warning": "Var medveten om att genom att posta din konversation på ShareGPT kommer den att bli offentligt tillgänglig och synlig för alla. När den väl är postad kan konversationen varken döljas eller raderas och kan arkiveras eller delas av andra. Vi rekommenderar dig att tänka noggrant igenom och undvika att dela känslig eller privat information på denna plattform."
@@ -37,5 +43,5 @@
"cloneChat": "Klona chatt",
"cloned": "Klonad",
"enterToSubmit": "Tryck på Enter för att skicka",
"submitPlaceholder": "Type a message or select [/] for a predefined prompt..."
"submitPlaceholder": "Skriv ett meddelande eller klicka på [/] för uppmaning..."
}

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

@@ -24,11 +24,17 @@
"setting": "设置",
"image": "图片",
"autoTitle": "自动生成标题",
"advancedMode": "高级模式",
"inlineLatex": "行内 Latex",
"prompt": "提示词",
"promptLibrary": "提示词资料库",
"name": "名称",
"search": "搜索",
"total": "合计",
"resetCost": "重置费用",
"countTotalTokens": "计算总 Token 数",
"morePrompts": "更多提示词请点击:",
"clearPrompts": "清除提示词",
"postOnShareGPT": {
"title": "发布至 ShareGPT",
"warning": "请注意,把您的对话发布到 ShareGPT 后,任何人都可以公开访问和查看。发布后,对话不能被隐藏或删除,且可能被其他人存档或分享。建议您慎重考虑,在这个平台上避免分享敏感或私密信息。"
@@ -37,5 +43,5 @@
"cloneChat": "创建聊天副本",
"cloned": "已创建副本",
"enterToSubmit": "按回车键提交",
"submitPlaceholder": "Type a message or select [/] for a predefined prompt..."
"submitPlaceholder": "输入消息或点击 [/] 以使用提示词…"
}

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

@@ -24,11 +24,17 @@
"setting": "設定",
"image": "圖片",
"autoTitle": "自動生成標題",
"advancedMode": "高級模式",
"inlineLatex": "行內 Latex",
"prompt": "Prompt",
"promptLibrary": "Prompt 資料庫",
"name": "名",
"search": "檢索",
"total": "合計",
"resetCost": "重置費用",
"countTotalTokens": "計算總 Token 數",
"morePrompts": "如果你想揾更多 prompt撳呢度",
"clearPrompts": "清空 prompts",
"postOnShareGPT": {
"title": "po 上 ShareGPT",
"warning": "請注意,你將呢個傾偈 po 上 ShareGPT 之後,佢會係公開嘅,所有人都可以見到你寫嘅嘢。一旦 po 咗,呢個傾偈將冇得被隱藏或刪除,亦都可能畀人存檔同分享。我哋建議你仔細諗下,唔好喺嗰度分享敏感或私人資料。"
@@ -37,5 +43,5 @@
"cloneChat": "建立傾偈副本",
"cloned": "建立成功",
"enterToSubmit": "撳 Enter 鍵提交",
"submitPlaceholder": "Type a message or select [/] for a predefined prompt..."
"submitPlaceholder": "輸入消息或點擊 [/] 以使用提示詞…"
}

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

@@ -24,11 +24,17 @@
"setting": "設定",
"image": "圖片",
"autoTitle": "自動生成標題",
"advancedMode": "高級模式",
"inlineLatex": "行內 Latex",
"prompt": "提示詞",
"promptLibrary": "提示詞資料庫",
"name": "名稱",
"search": "搜尋",
"total": "合計",
"resetCost": "重置費用",
"countTotalTokens": "計算總 Token 數",
"morePrompts": "更多提示詞請點選:",
"clearPrompts": "清除提示詞",
"postOnShareGPT": {
"title": "發佈至 ShareGPT",
"warning": "請注意,將您的對話發佈至 ShareGPT 後,任何人都可以公開訪問和查看。一旦發佈,對話將無法隱藏或刪除,並且可能被他人存檔或分享。我們建議您慎重考慮,並避免在此平台上分享敏感或私人信息。"
@@ -37,5 +43,5 @@
"cloneChat": "創建聊天副本",
"cloned": "已創建副本",
"enterToSubmit": "按回車鍵提交",
"submitPlaceholder": "Type a message or select [/] for a predefined prompt..."
"submitPlaceholder": "輸入消息或點擊 [/] 以使用提示詞…"
}

View File

@@ -9,6 +9,8 @@ import useInitialiseNewChat from '@hooks/useInitialiseNewChat';
import { ChatInterface } from '@type/chat';
import { Theme } from '@type/theme';
import ApiPopup from '@components/ApiPopup';
import Toast from '@components/Toast';
import { rtlLanguages } from '@constants/language';
function App() {
@@ -84,6 +86,7 @@ function App() {
<Menu />
<Chat />
<ApiPopup />
<Toast />
</div>
);
}

View File

@@ -22,7 +22,7 @@ export const getChatCompletion = async (
body: JSON.stringify({
messages,
...config,
max_tokens: null,
max_tokens: undefined,
}),
});
if (!response.ok) throw new Error(await response.text());
@@ -51,7 +51,7 @@ export const getChatCompletionStream = async (
body: JSON.stringify({
messages,
...config,
max_tokens: null,
max_tokens: undefined,
stream: true,
}),
});
@@ -75,8 +75,8 @@ export const getChatCompletionStream = async (
if (text.includes('insufficient_quota')) {
error +=
'\nMessage from Better ChatGPT:\nWe recommend changing your API endpoint or API key';
} else {
error += '\nRate limited! Please try again later.';
} else if (response.status === 429) {
error += '\nRate limited!';
}
throw new Error(error);
}

View File

@@ -1,67 +0,0 @@
import { ConfigInterface, MessageInterface } from '@type/chat';
export const endpoint = 'https://api.openai.com/v1/chat/completions';
export const validateApiKey = async (apiKey: string) => {
try {
const response = await fetch(endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${apiKey}`,
},
});
const data = await response.json();
if (response.status === 401) return false;
else if (response.status === 400) return true;
} catch (error) {
console.error('Error:', error);
return false;
}
};
export const getChatCompletion = async (
apiKey: string,
messages: MessageInterface[],
config: ConfigInterface
) => {
const response = await fetch(endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify({
messages,
...config,
}),
});
if (!response.ok) throw new Error(await response.text());
const data = await response.json();
return data;
};
export const getChatCompletionStream = async (
apiKey: string,
messages: MessageInterface[],
config: ConfigInterface
) => {
const response = await fetch(endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify({
messages,
...config,
stream: true,
}),
});
if (!response.ok) throw new Error(await response.text());
const stream = response.body;
return stream;
};

View File

@@ -1,57 +0,0 @@
import { ConfigInterface, MessageInterface } from '@type/chat';
export const getChatCompletion = async (
endpoint: string,
messages: MessageInterface[],
config: ConfigInterface
) => {
const response = await fetch(endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
messages,
...config,
}),
});
if (!response.ok) throw new Error(await response.text());
const data = await response.json();
return data;
};
export const getChatCompletionStream = async (
endpoint: string,
messages: MessageInterface[],
config: ConfigInterface
) => {
const response = await fetch(endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
messages,
...config,
stream: true,
}),
});
if (response.status === 404 || response.status === 405)
throw new Error(
'Message from Better ChatGPT:\nInvalid API endpoint! We recommend you to check your free API endpoint.'
);
if (response.status === 429 || !response.ok) {
const text = await response.text();
let error = text;
if (text.includes('insufficient_quota')) {
error +=
'\nMessage from Better ChatGPT:\nWe recommend changing your API endpoint or API key';
}
throw new Error(error);
}
const stream = response.body;
return stream;
};

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;
};
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 CalculatorIcon = (props: React.SVGProps<SVGSVGElement>) => {
return (
<svg
fill='currentColor'
viewBox='0 0 16 16'
height='1em'
width='1em'
{...props}
>
<path d='M2 2a2 2 0 012-2h8a2 2 0 012 2v12a2 2 0 01-2 2H4a2 2 0 01-2-2V2zm2 .5v2a.5.5 0 00.5.5h7a.5.5 0 00.5-.5v-2a.5.5 0 00-.5-.5h-7a.5.5 0 00-.5.5zm0 4v1a.5.5 0 00.5.5h1a.5.5 0 00.5-.5v-1a.5.5 0 00-.5-.5h-1a.5.5 0 00-.5.5zM4.5 9a.5.5 0 00-.5.5v1a.5.5 0 00.5.5h1a.5.5 0 00.5-.5v-1a.5.5 0 00-.5-.5h-1zM4 12.5v1a.5.5 0 00.5.5h1a.5.5 0 00.5-.5v-1a.5.5 0 00-.5-.5h-1a.5.5 0 00-.5.5zM7.5 6a.5.5 0 00-.5.5v1a.5.5 0 00.5.5h1a.5.5 0 00.5-.5v-1a.5.5 0 00-.5-.5h-1zM7 9.5v1a.5.5 0 00.5.5h1a.5.5 0 00.5-.5v-1a.5.5 0 00-.5-.5h-1a.5.5 0 00-.5.5zm.5 2.5a.5.5 0 00-.5.5v1a.5.5 0 00.5.5h1a.5.5 0 00.5-.5v-1a.5.5 0 00-.5-.5h-1zM10 6.5v1a.5.5 0 00.5.5h1a.5.5 0 00.5-.5v-1a.5.5 0 00-.5-.5h-1a.5.5 0 00-.5.5zm.5 2.5a.5.5 0 00-.5.5v4a.5.5 0 00.5.5h1a.5.5 0 00.5-.5v-4a.5.5 0 00-.5-.5h-1z' />
</svg>
);
};
export default CalculatorIcon;

View File

@@ -0,0 +1,17 @@
import React from 'react';
const FileTextIcon = (props: React.SVGProps<SVGSVGElement>) => {
return (
<svg
viewBox='0 0 1024 1024'
fill='currentColor'
height='1em'
width='1em'
{...props}
>
<path d='M854.6 288.6L639.4 73.4c-6-6-14.1-9.4-22.6-9.4H192c-17.7 0-32 14.3-32 32v832c0 17.7 14.3 32 32 32h640c17.7 0 32-14.3 32-32V311.3c0-8.5-3.4-16.7-9.4-22.7zM790.2 326H602V137.8L790.2 326zm1.8 562H232V136h302v216a42 42 0 0042 42h216v494zM504 618H320c-4.4 0-8 3.6-8 8v48c0 4.4 3.6 8 8 8h184c4.4 0 8-3.6 8-8v-48c0-4.4-3.6-8-8-8zM312 490v48c0 4.4 3.6 8 8 8h384c4.4 0 8-3.6 8-8v-48c0-4.4-3.6-8-8-8H320c-4.4 0-8 3.6-8 8z' />
</svg>
);
};
export default FileTextIcon;

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

@@ -0,0 +1,18 @@
import React from 'react';
const MoneyIcon = (props: React.SVGProps<SVGSVGElement>) => {
return (
<svg
viewBox='0 0 24 24'
fill='currentColor'
height='1em'
width='1em'
{...props}
>
<path fill='none' d='M0 0h24v24H0z' />
<path d='M3 3h18a1 1 0 011 1v16a1 1 0 01-1 1H3a1 1 0 01-1-1V4a1 1 0 011-1zm5.5 11v2H11v2h2v-2h1a2.5 2.5 0 100-5h-4a.5.5 0 110-1h5.5V8H13V6h-2v2h-1a2.5 2.5 0 000 5h4a.5.5 0 110 1H8.5z' />
</svg>
);
};
export default MoneyIcon;

View File

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

View File

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

View File

@@ -10,7 +10,7 @@ const AboutMenu = () => {
return (
<>
<a
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'
className='flex py-2 px-2 items-center gap-3 rounded-md hover:bg-gray-500/10 transition-colors duration-200 text-white cursor-pointer text-sm'
onClick={() => {
setIsModalOpen(true);
}}
@@ -97,6 +97,13 @@ const AboutMenu = () => {
<p>{t('support.paragraph3', { ns: 'about' })}</p>
<div className='flex flex-col items-center gap-4 my-4'>
<a href='https://github.com/sponsors/ztjhz' target='_blank'>
<img
src='https://img.shields.io/static/v1?label=Sponsor&message=%E2%9D%A4&logo=GitHub&color=%23fe8e86'
width='120px'
alt='Support us through GitHub Sponsors'
/>
</a>
<a href='https://ko-fi.com/betterchatgpt' target='_blank'>
<img
src='./kofi.svg'

View File

@@ -2,8 +2,12 @@ import React, { useEffect, useState } from 'react';
import { useTranslation, Trans } from 'react-i18next';
import useStore from '@store/store';
import useHideOnOutsideClick from '@hooks/useHideOnOutsideClick';
import PopupModal from '@components/PopupModal';
import { availableEndpoints, defaultAPIEndpoint } from '@constants/auth';
import DownChevronArrow from '@icon/DownChevronArrow';
const ApiMenu = ({
@@ -53,7 +57,7 @@ const ApiMenu = ({
{t('customEndpoint', { ns: 'api' })}
</label>
<div className='flex gap-2 items-center justify-center mb-6'>
<div className='flex gap-2 items-center mb-6'>
<div className='min-w-fit text-gray-900 dark:text-gray-300 text-sm'>
{t('apiEndpoint.inputLabel', { ns: 'api' })}
</div>
@@ -121,20 +125,21 @@ const ApiEndpointSelector = ({
_apiEndpoint: string;
_setApiEndpoint: React.Dispatch<React.SetStateAction<string>>;
}) => {
const [dropDown, setDropDown] = useState<boolean>(false);
const [dropDown, setDropDown, dropDownRef] = useHideOnOutsideClick();
return (
<div className='w-full relative'>
<div className='w-[40vw] relative flex-1'>
<button
className='btn btn-neutral btn-small flex w-32 flex justify-between w-full'
className='btn btn-neutral btn-small flex justify-between w-full'
type='button'
onClick={() => setDropDown((prev) => !prev)}
>
{_apiEndpoint}
<span className='truncate'>{_apiEndpoint}</span>
<DownChevronArrow />
</button>
<div
id='dropdown'
ref={dropDownRef}
className={`${
dropDown ? '' : 'hidden'
} absolute top-100 bottom-100 z-10 bg-white rounded-lg shadow-xl border-b border-black/10 dark:border-gray-900/50 text-gray-800 dark:text-gray-100 group dark:bg-gray-800 opacity-90 w-32 w-full`}
@@ -145,7 +150,7 @@ const ApiEndpointSelector = ({
>
{availableEndpoints.map((endpoint) => (
<li
className='px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white cursor-pointer'
className='px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white cursor-pointer truncate'
onClick={() => {
_setApiEndpoint(endpoint);
setDropDown(false);

View File

@@ -32,7 +32,9 @@ const ChatContent = () => {
? state.chats[state.currentChatIndex].messages.length
: 0
);
const advancedMode = useStore((state) => state.advancedMode);
const generating = useStore.getState().generating;
const hideSideMenu = useStore((state) => state.hideSideMenu);
const saveRef = useRef<HTMLDivElement>(null);
@@ -57,8 +59,10 @@ const ChatContent = () => {
className='flex flex-col items-center text-sm dark:bg-gray-800 w-full'
ref={saveRef}
>
<ChatTitle />
{messages?.length === 0 && <NewMessageButton messageIndex={-1} />}
{advancedMode && <ChatTitle />}
{!generating && advancedMode && messages?.length === 0 && (
<NewMessageButton messageIndex={-1} />
)}
{messages?.map((message, index) => (
<React.Fragment key={index}>
<Message
@@ -66,7 +70,7 @@ const ChatContent = () => {
content={message.content}
messageIndex={index}
/>
<NewMessageButton messageIndex={index} />
{!generating && advancedMode && <NewMessageButton messageIndex={index} />}
</React.Fragment>
))}
</div>
@@ -92,13 +96,19 @@ const ChatContent = () => {
</div>
</div>
)}
<div className='mt-4 flex gap-4 flex-wrap justify-center'>
<div
className={`mt-4 w-full m-auto ${
hideSideMenu
? 'md:max-w-5xl lg:max-w-5xl xl:max-w-6xl'
: 'md:max-w-3xl lg:max-w-3xl xl:max-w-4xl'
}`}
>
{useStore.getState().generating || (
<>
<div className='md:w-[calc(100%-50px)] flex gap-4 flex-wrap justify-center'>
<DownloadChat saveRef={saveRef} />
<ShareGPT />
<CloneChat />
</>
</div>
)}
</div>
<div className='w-full h-36'></div>

View File

@@ -1,9 +1,12 @@
import React, { useEffect, useState } from 'react';
import React, { useEffect, useRef, useState } from 'react';
import useStore from '@store/store';
import { useTranslation } from 'react-i18next';
import { matchSorter } from 'match-sorter';
import { Prompt } from '@type/prompt';
import useHideOnOutsideClick from '@hooks/useHideOnOutsideClick';
const CommandPrompt = ({
_setContent,
}: {
@@ -11,10 +14,20 @@ const CommandPrompt = ({
}) => {
const { t } = useTranslation();
const prompts = useStore((state) => state.prompts);
const [dropDown, setDropDown] = useState<boolean>(false);
const [_prompts, _setPrompts] = useState<Prompt[]>(prompts);
const [input, setInput] = useState<string>('');
const inputRef = useRef<HTMLInputElement>(null);
const [dropDown, setDropDown, dropDownRef] = useHideOnOutsideClick();
useEffect(() => {
if (dropDown && inputRef.current) {
// When dropdown is visible, focus the input
inputRef.current.focus();
}
}, [dropDown]);
useEffect(() => {
const filteredPrompts = matchSorter(useStore.getState().prompts, input, {
keys: ['name'],
@@ -28,7 +41,7 @@ const CommandPrompt = ({
}, [prompts]);
return (
<div className='relative max-wd-sm'>
<div className='relative max-wd-sm' ref={dropDownRef}>
<button
className='btn btn-neutral btn-small'
onClick={() => setDropDown(!dropDown)}
@@ -42,6 +55,7 @@ const CommandPrompt = ({
>
<div className='text-sm px-4 py-2 w-max'>{t('promptLibrary')}</div>
<input
ref={inputRef}
type='text'
className='text-gray-800 dark:text-white p-3 text-sm border-none bg-gray-200 dark:bg-gray-600 m-0 w-full mr-0 h-8 focus:outline-none'
value={input}

View File

@@ -27,6 +27,7 @@ const Message = React.memo(
sticky?: boolean;
}) => {
const hideSideMenu = useStore((state) => state.hideSideMenu);
const advancedMode = useStore((state) => state.advancedMode);
return (
<div
@@ -43,11 +44,12 @@ const Message = React.memo(
>
<Avatar role={role} />
<div className='w-[calc(100%-50px)] '>
<RoleSelector
role={role}
messageIndex={messageIndex}
sticky={sticky}
/>
{advancedMode &&
<RoleSelector
role={role}
messageIndex={messageIndex}
sticky={sticky}
/>}
<MessageContent
role={role}
content={content}

View File

@@ -1,35 +1,8 @@
import React, {
DetailedHTMLProps,
HTMLAttributes,
useEffect,
useState,
} from 'react';
import { useTranslation } from 'react-i18next';
import ReactMarkdown from 'react-markdown';
import { CodeProps, ReactMarkdownProps } from 'react-markdown/lib/ast-to-react';
import rehypeKatex from 'rehype-katex';
import rehypeHighlight from 'rehype-highlight';
import remarkMath from 'remark-math';
import remarkGfm from 'remark-gfm';
import React, { useState } from 'react';
import useStore from '@store/store';
import EditIcon2 from '@icon/EditIcon2';
import DeleteIcon from '@icon/DeleteIcon';
import TickIcon from '@icon/TickIcon';
import CrossIcon from '@icon/CrossIcon';
import RefreshIcon from '@icon/RefreshIcon';
import DownChevronArrow from '@icon/DownChevronArrow';
import CopyIcon from '@icon/CopyIcon';
import useSubmit from '@hooks/useSubmit';
import { ChatInterface } from '@type/chat';
import PopupModal from '@components/PopupModal';
import TokenCount from '@components/TokenCount';
import CommandPrompt from './CommandPrompt';
import CodeBlock from './CodeBlock';
import { codeLanguageSubset } from '@constants/chat';
import ContentView from './View/ContentView';
import EditView from './View/EditView';
const MessageContent = ({
role,
@@ -43,10 +16,11 @@ const MessageContent = ({
sticky?: boolean;
}) => {
const [isEdit, setIsEdit] = useState<boolean>(sticky);
const advancedMode = useStore((state) => state.advancedMode);
return (
<div className='relative flex flex-col gap-1 md:gap-3 lg:w-[calc(100%-115px)]'>
<div className='flex flex-grow flex-col gap-3'></div>
{advancedMode && <div className='flex flex-grow flex-col gap-3'></div>}
{isEdit ? (
<EditView
content={content}
@@ -66,476 +40,4 @@ const MessageContent = ({
);
};
const ContentView = React.memo(
({
role,
content,
setIsEdit,
messageIndex,
}: {
role: string;
content: string;
setIsEdit: React.Dispatch<React.SetStateAction<boolean>>;
messageIndex: number;
}) => {
const { handleSubmit } = useSubmit();
const [isDelete, setIsDelete] = useState<boolean>(false);
const currentChatIndex = useStore((state) => state.currentChatIndex);
const setChats = useStore((state) => state.setChats);
const lastMessageIndex = useStore((state) =>
state.chats ? state.chats[state.currentChatIndex].messages.length - 1 : 0
);
const handleDelete = () => {
const updatedChats: ChatInterface[] = JSON.parse(
JSON.stringify(useStore.getState().chats)
);
updatedChats[currentChatIndex].messages.splice(messageIndex, 1);
setChats(updatedChats);
};
const handleMove = (direction: 'up' | 'down') => {
const updatedChats: ChatInterface[] = JSON.parse(
JSON.stringify(useStore.getState().chats)
);
const updatedMessages = updatedChats[currentChatIndex].messages;
const temp = updatedMessages[messageIndex];
if (direction === 'up') {
updatedMessages[messageIndex] = updatedMessages[messageIndex - 1];
updatedMessages[messageIndex - 1] = temp;
} else {
updatedMessages[messageIndex] = updatedMessages[messageIndex + 1];
updatedMessages[messageIndex + 1] = temp;
}
setChats(updatedChats);
};
const handleMoveUp = () => {
handleMove('up');
};
const handleMoveDown = () => {
handleMove('down');
};
const handleRefresh = () => {
const updatedChats: ChatInterface[] = JSON.parse(
JSON.stringify(useStore.getState().chats)
);
const updatedMessages = updatedChats[currentChatIndex].messages;
updatedMessages.splice(updatedMessages.length - 1, 1);
setChats(updatedChats);
handleSubmit();
};
const handleCopy = () => {
navigator.clipboard.writeText(content);
};
return (
<>
<div className='markdown prose w-full md:max-w-full break-words dark:prose-invert dark share-gpt-message'>
<ReactMarkdown
remarkPlugins={[
remarkGfm,
[remarkMath, { singleDollarTextMath: false }],
]}
rehypePlugins={[
[rehypeKatex, { output: 'mathml' }],
[
rehypeHighlight,
{
detect: true,
ignoreMissing: true,
subset: codeLanguageSubset,
},
],
]}
linkTarget='_new'
components={{
code,
p,
}}
>
{content}
</ReactMarkdown>
</div>
<div className='flex justify-end gap-2 w-full mt-2'>
{isDelete || (
<>
{!useStore.getState().generating &&
role === 'assistant' &&
messageIndex === lastMessageIndex && (
<RefreshButton onClick={handleRefresh} />
)}
{messageIndex !== 0 && <UpButton onClick={handleMoveUp} />}
{messageIndex !== lastMessageIndex && (
<DownButton onClick={handleMoveDown} />
)}
<CopyButton onClick={handleCopy} />
<EditButton setIsEdit={setIsEdit} />
<DeleteButton setIsDelete={setIsDelete} />
</>
)}
{isDelete && (
<>
<button
className='p-1 hover:text-white'
onClick={() => setIsDelete(false)}
>
<CrossIcon />
</button>
<button className='p-1 hover:text-white' onClick={handleDelete}>
<TickIcon />
</button>
</>
)}
</div>
</>
);
}
);
const code = React.memo((props: CodeProps) => {
const { inline, className, children } = props;
const match = /language-(\w+)/.exec(className || '');
const lang = match && match[1];
if (inline) {
return <code className={className}>{children}</code>;
} else {
return <CodeBlock lang={lang || 'text'} codeChildren={children} />;
}
});
const p = React.memo(
(
props?: Omit<
DetailedHTMLProps<
HTMLAttributes<HTMLParagraphElement>,
HTMLParagraphElement
>,
'ref'
> &
ReactMarkdownProps
) => {
return <p className='whitespace-pre-wrap'>{props?.children}</p>;
}
);
const MessageButton = ({
onClick,
icon,
}: {
onClick: React.MouseEventHandler<HTMLButtonElement>;
icon: React.ReactElement;
}) => {
return (
<div className='text-gray-400 flex self-end lg:self-center justify-center gap-3 md:gap-4 visible'>
<button
className='p-1 rounded-md hover:bg-gray-100 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-gray-200 disabled:dark:hover:text-gray-400 md:invisible md:group-hover:visible'
onClick={onClick}
>
{icon}
</button>
</div>
);
};
const EditButton = React.memo(
({
setIsEdit,
}: {
setIsEdit: React.Dispatch<React.SetStateAction<boolean>>;
}) => {
return (
<MessageButton icon={<EditIcon2 />} onClick={() => setIsEdit(true)} />
);
}
);
const DeleteButton = React.memo(
({
setIsDelete,
}: {
setIsDelete: React.Dispatch<React.SetStateAction<boolean>>;
}) => {
return (
<MessageButton icon={<DeleteIcon />} onClick={() => setIsDelete(true)} />
);
}
);
const DownButton = ({
onClick,
}: {
onClick: React.MouseEventHandler<HTMLButtonElement>;
}) => {
return <MessageButton icon={<DownChevronArrow />} onClick={onClick} />;
};
const UpButton = ({
onClick,
}: {
onClick: React.MouseEventHandler<HTMLButtonElement>;
}) => {
return (
<MessageButton
icon={<DownChevronArrow className='rotate-180' />}
onClick={onClick}
/>
);
};
const RefreshButton = ({
onClick,
}: {
onClick: React.MouseEventHandler<HTMLButtonElement>;
}) => {
return <MessageButton icon={<RefreshIcon />} onClick={onClick} />;
};
const CopyButton = ({
onClick,
}: {
onClick: React.MouseEventHandler<HTMLButtonElement>;
}) => {
const [isCopied, setIsCopied] = useState<boolean>(false);
return (
<MessageButton
icon={isCopied ? <TickIcon /> : <CopyIcon />}
onClick={(e) => {
onClick(e);
setIsCopied(true);
window.setTimeout(() => {
setIsCopied(false);
}, 3000);
}}
/>
);
};
const EditView = ({
content,
setIsEdit,
messageIndex,
sticky,
}: {
content: string;
setIsEdit: React.Dispatch<React.SetStateAction<boolean>>;
messageIndex: number;
sticky?: boolean;
}) => {
const inputRole = useStore((state) => state.inputRole);
const setChats = useStore((state) => state.setChats);
const currentChatIndex = useStore((state) => state.currentChatIndex);
const [_content, _setContent] = useState<string>(content);
const [isModalOpen, setIsModalOpen] = useState<boolean>(false);
const textareaRef = React.createRef<HTMLTextAreaElement>();
const { t } = useTranslation();
const resetTextAreaHeight = () => {
if (textareaRef.current) textareaRef.current.style.height = 'auto';
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
const isMobile =
/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini|playbook|silk/i.test(
navigator.userAgent
);
if (e.key === 'Enter' && !isMobile && !e.nativeEvent.isComposing) {
const enterToSubmit = useStore.getState().enterToSubmit;
if (sticky) {
if (
(enterToSubmit && !e.shiftKey) ||
(!enterToSubmit && (e.ctrlKey || e.shiftKey))
) {
e.preventDefault();
handleSaveAndSubmit();
resetTextAreaHeight();
}
} else {
if (e.ctrlKey && e.shiftKey) {
e.preventDefault();
handleSaveAndSubmit();
resetTextAreaHeight();
} else if (e.ctrlKey || e.shiftKey) handleSave();
}
}
};
const handleSave = () => {
if (sticky && _content === '') return;
const updatedChats: ChatInterface[] = JSON.parse(
JSON.stringify(useStore.getState().chats)
);
const updatedMessages = updatedChats[currentChatIndex].messages;
if (sticky) {
updatedMessages.push({ role: inputRole, content: _content });
_setContent('');
resetTextAreaHeight();
} else {
updatedMessages[messageIndex].content = _content;
setIsEdit(false);
}
setChats(updatedChats);
};
const { handleSubmit } = useSubmit();
const handleSaveAndSubmit = () => {
const updatedChats: ChatInterface[] = JSON.parse(
JSON.stringify(useStore.getState().chats)
);
const updatedMessages = updatedChats[currentChatIndex].messages;
if (sticky) {
if (_content !== '') {
updatedMessages.push({ role: inputRole, content: _content });
}
_setContent('');
resetTextAreaHeight();
} else {
updatedMessages[messageIndex].content = _content;
updatedChats[currentChatIndex].messages = updatedMessages.slice(
0,
messageIndex + 1
);
setIsEdit(false);
}
setChats(updatedChats);
handleSubmit();
};
useEffect(() => {
if (textareaRef.current) {
textareaRef.current.style.height = 'auto';
textareaRef.current.style.height = `${textareaRef.current.scrollHeight}px`;
}
}, [_content]);
useEffect(() => {
if (textareaRef.current) {
textareaRef.current.style.height = 'auto';
textareaRef.current.style.height = `${textareaRef.current.scrollHeight}px`;
}
}, []);
return (
<>
<div
className={`w-full ${
sticky
? 'py-2 md:py-3 px-2 md:px-4 border border-black/10 bg-white dark:border-gray-900/50 dark:text-white dark:bg-gray-700 rounded-md shadow-[0_0_10px_rgba(0,0,0,0.10)] dark:shadow-[0_0_15px_rgba(0,0,0,0.10)]'
: ''
}`}
>
<textarea
ref={textareaRef}
className='m-0 resize-none rounded-lg bg-transparent overflow-y-hidden focus:ring-0 focus-visible:ring-0 leading-7 w-full placeholder:text-gray-500/40'
onChange={(e) => {
_setContent(e.target.value);
}}
value={_content}
placeholder={t('submitPlaceholder') as string}
onKeyDown={handleKeyDown}
rows={1}
></textarea>
</div>
<EditViewButtons
sticky={sticky}
handleSaveAndSubmit={handleSaveAndSubmit}
handleSave={handleSave}
setIsModalOpen={setIsModalOpen}
setIsEdit={setIsEdit}
_setContent={_setContent}
/>
{isModalOpen && (
<PopupModal
setIsModalOpen={setIsModalOpen}
title={t('warning') as string}
message={t('clearMessageWarning') as string}
handleConfirm={handleSaveAndSubmit}
/>
)}
</>
);
};
const EditViewButtons = React.memo(
({
sticky = false,
handleSaveAndSubmit,
handleSave,
setIsModalOpen,
setIsEdit,
_setContent,
}: {
sticky?: boolean;
handleSaveAndSubmit: () => void;
handleSave: () => void;
setIsModalOpen: React.Dispatch<React.SetStateAction<boolean>>;
setIsEdit: React.Dispatch<React.SetStateAction<boolean>>;
_setContent: React.Dispatch<React.SetStateAction<string>>;
}) => {
const { t } = useTranslation();
return (
<div className='flex'>
<div className='flex-1 text-center mt-2 flex justify-center'>
{sticky && (
<button
className='btn relative mr-2 btn-primary'
onClick={handleSaveAndSubmit}
>
<div className='flex items-center justify-center gap-2'>
{t('saveAndSubmit')}
</div>
</button>
)}
<button
className={`btn relative mr-2 ${
sticky ? 'btn-neutral' : 'btn-primary'
}`}
onClick={handleSave}
>
<div className='flex items-center justify-center gap-2'>
{t('save')}
</div>
</button>
{sticky || (
<button
className='btn relative mr-2 btn-neutral'
onClick={() => {
setIsModalOpen(true);
}}
>
<div className='flex items-center justify-center gap-2'>
{t('saveAndSubmit')}
</div>
</button>
)}
{sticky || (
<button
className='btn relative btn-neutral'
onClick={() => setIsEdit(false)}
>
<div className='flex items-center justify-center gap-2'>
{t('cancel')}
</div>
</button>
)}
</div>
{sticky && <TokenCount />}
<CommandPrompt _setContent={_setContent} />
</div>
);
}
);
export default MessageContent;

View File

@@ -1,10 +1,12 @@
import React, { useState } from 'react';
import React, { useState, useEffect, useCallback, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import useStore from '@store/store';
import DownChevronArrow from '@icon/DownChevronArrow';
import { ChatInterface, Role, roles } from '@type/chat';
import useHideOnOutsideClick from '@hooks/useHideOnOutsideClick';
const RoleSelector = React.memo(
({
role,
@@ -20,7 +22,7 @@ const RoleSelector = React.memo(
const setChats = useStore((state) => state.setChats);
const currentChatIndex = useStore((state) => state.currentChatIndex);
const [dropDown, setDropDown] = useState<boolean>(false);
const [dropDown, setDropDown, dropDownRef] = useHideOnOutsideClick();
return (
<div className='prose dark:prose-invert relative'>
@@ -33,6 +35,7 @@ const RoleSelector = React.memo(
<DownChevronArrow />
</button>
<div
ref={dropDownRef}
id='dropdown'
className={`${
dropDown ? '' : 'hidden'

View File

@@ -0,0 +1,22 @@
import React from 'react';
const BaseButton = ({
onClick,
icon,
}: {
onClick: React.MouseEventHandler<HTMLButtonElement>;
icon: React.ReactElement;
}) => {
return (
<div className='text-gray-400 flex self-end lg:self-center justify-center gap-3 md:gap-4 visible'>
<button
className='p-1 rounded-md hover:bg-gray-100 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-gray-200 disabled:dark:hover:text-gray-400 md:invisible md:group-hover:visible'
onClick={onClick}
>
{icon}
</button>
</div>
);
};
export default BaseButton;

View File

@@ -0,0 +1,29 @@
import React, { useState } from 'react';
import TickIcon from '@icon/TickIcon';
import CopyIcon from '@icon/CopyIcon';
import BaseButton from './BaseButton';
const CopyButton = ({
onClick,
}: {
onClick: React.MouseEventHandler<HTMLButtonElement>;
}) => {
const [isCopied, setIsCopied] = useState<boolean>(false);
return (
<BaseButton
icon={isCopied ? <TickIcon /> : <CopyIcon />}
onClick={(e) => {
onClick(e);
setIsCopied(true);
window.setTimeout(() => {
setIsCopied(false);
}, 3000);
}}
/>
);
};
export default CopyButton;

View File

@@ -0,0 +1,19 @@
import React, { memo } from 'react';
import DeleteIcon from '@icon/DeleteIcon';
import BaseButton from './BaseButton';
const DeleteButton = memo(
({
setIsDelete,
}: {
setIsDelete: React.Dispatch<React.SetStateAction<boolean>>;
}) => {
return (
<BaseButton icon={<DeleteIcon />} onClick={() => setIsDelete(true)} />
);
}
);
export default DeleteButton;

View File

@@ -0,0 +1,15 @@
import React from 'react';
import DownChevronArrow from '@icon/DownChevronArrow';
import BaseButton from './BaseButton';
const DownButton = ({
onClick,
}: {
onClick: React.MouseEventHandler<HTMLButtonElement>;
}) => {
return <BaseButton icon={<DownChevronArrow />} onClick={onClick} />;
};
export default DownButton;

View File

@@ -0,0 +1,17 @@
import React, { memo } from 'react';
import EditIcon2 from '@icon/EditIcon2';
import BaseButton from './BaseButton';
const EditButton = memo(
({
setIsEdit,
}: {
setIsEdit: React.Dispatch<React.SetStateAction<boolean>>;
}) => {
return <BaseButton icon={<EditIcon2 />} onClick={() => setIsEdit(true)} />;
}
);
export default EditButton;

View File

@@ -0,0 +1,24 @@
import React, { useState } from 'react';
import useStore from '@store/store';
import BaseButton from './BaseButton';
import MarkdownIcon from '@icon/MarkdownIcon';
import FileTextIcon from '@icon/FileTextIcon';
const MarkdownModeButton = () => {
const markdownMode = useStore((state) => state.markdownMode);
const setMarkdownMode = useStore((state) => state.setMarkdownMode);
return (
<BaseButton
icon={markdownMode ? <MarkdownIcon /> : <FileTextIcon />}
onClick={() => {
setMarkdownMode(!markdownMode);
}}
/>
);
};
export default MarkdownModeButton;

View File

@@ -0,0 +1,15 @@
import React from 'react';
import RefreshIcon from '@icon/RefreshIcon';
import BaseButton from './BaseButton';
const RefreshButton = ({
onClick,
}: {
onClick: React.MouseEventHandler<HTMLButtonElement>;
}) => {
return <BaseButton icon={<RefreshIcon />} onClick={onClick} />;
};
export default RefreshButton;

View File

@@ -0,0 +1,20 @@
import React from 'react';
import DownChevronArrow from '@icon/DownChevronArrow';
import BaseButton from './BaseButton';
const UpButton = ({
onClick,
}: {
onClick: React.MouseEventHandler<HTMLButtonElement>;
}) => {
return (
<BaseButton
icon={<DownChevronArrow className='rotate-180' />}
onClick={onClick}
/>
);
};
export default UpButton;

View File

@@ -0,0 +1,203 @@
import React, {
DetailedHTMLProps,
HTMLAttributes,
memo,
useState,
} from 'react';
import ReactMarkdown from 'react-markdown';
import { CodeProps, ReactMarkdownProps } from 'react-markdown/lib/ast-to-react';
import rehypeKatex from 'rehype-katex';
import rehypeHighlight from 'rehype-highlight';
import remarkMath from 'remark-math';
import remarkGfm from 'remark-gfm';
import useStore from '@store/store';
import TickIcon from '@icon/TickIcon';
import CrossIcon from '@icon/CrossIcon';
import useSubmit from '@hooks/useSubmit';
import { ChatInterface } from '@type/chat';
import { codeLanguageSubset } from '@constants/chat';
import RefreshButton from './Button/RefreshButton';
import UpButton from './Button/UpButton';
import DownButton from './Button/DownButton';
import CopyButton from './Button/CopyButton';
import EditButton from './Button/EditButton';
import DeleteButton from './Button/DeleteButton';
import MarkdownModeButton from './Button/MarkdownModeButton';
import CodeBlock from '../CodeBlock';
const ContentView = memo(
({
role,
content,
setIsEdit,
messageIndex,
}: {
role: string;
content: string;
setIsEdit: React.Dispatch<React.SetStateAction<boolean>>;
messageIndex: number;
}) => {
const { handleSubmit } = useSubmit();
const [isDelete, setIsDelete] = useState<boolean>(false);
const currentChatIndex = useStore((state) => state.currentChatIndex);
const setChats = useStore((state) => state.setChats);
const lastMessageIndex = useStore((state) =>
state.chats ? state.chats[state.currentChatIndex].messages.length - 1 : 0
);
const inlineLatex = useStore((state) => state.inlineLatex);
const markdownMode = useStore((state) => state.markdownMode);
const handleDelete = () => {
const updatedChats: ChatInterface[] = JSON.parse(
JSON.stringify(useStore.getState().chats)
);
updatedChats[currentChatIndex].messages.splice(messageIndex, 1);
setChats(updatedChats);
};
const handleMove = (direction: 'up' | 'down') => {
const updatedChats: ChatInterface[] = JSON.parse(
JSON.stringify(useStore.getState().chats)
);
const updatedMessages = updatedChats[currentChatIndex].messages;
const temp = updatedMessages[messageIndex];
if (direction === 'up') {
updatedMessages[messageIndex] = updatedMessages[messageIndex - 1];
updatedMessages[messageIndex - 1] = temp;
} else {
updatedMessages[messageIndex] = updatedMessages[messageIndex + 1];
updatedMessages[messageIndex + 1] = temp;
}
setChats(updatedChats);
};
const handleMoveUp = () => {
handleMove('up');
};
const handleMoveDown = () => {
handleMove('down');
};
const handleRefresh = () => {
const updatedChats: ChatInterface[] = JSON.parse(
JSON.stringify(useStore.getState().chats)
);
const updatedMessages = updatedChats[currentChatIndex].messages;
updatedMessages.splice(updatedMessages.length - 1, 1);
setChats(updatedChats);
handleSubmit();
};
const handleCopy = () => {
navigator.clipboard.writeText(content);
};
return (
<>
<div className='markdown prose w-full md:max-w-full break-words dark:prose-invert dark share-gpt-message'>
{markdownMode ? (
<ReactMarkdown
remarkPlugins={[
remarkGfm,
[remarkMath, { singleDollarTextMath: inlineLatex }],
]}
rehypePlugins={[
rehypeKatex,
[
rehypeHighlight,
{
detect: true,
ignoreMissing: true,
subset: codeLanguageSubset,
},
],
]}
linkTarget='_new'
components={{
code,
p,
}}
>
{content}
</ReactMarkdown>
) : (
<span className='whitespace-pre-wrap'>{content}</span>
)}
</div>
<div className='flex justify-end gap-2 w-full mt-2'>
{isDelete || (
<>
{!useStore.getState().generating &&
role === 'assistant' &&
messageIndex === lastMessageIndex && (
<RefreshButton onClick={handleRefresh} />
)}
{messageIndex !== 0 && <UpButton onClick={handleMoveUp} />}
{messageIndex !== lastMessageIndex && (
<DownButton onClick={handleMoveDown} />
)}
<MarkdownModeButton />
<CopyButton onClick={handleCopy} />
<EditButton setIsEdit={setIsEdit} />
<DeleteButton setIsDelete={setIsDelete} />
</>
)}
{isDelete && (
<>
<button
className='p-1 hover:text-white'
onClick={() => setIsDelete(false)}
>
<CrossIcon />
</button>
<button className='p-1 hover:text-white' onClick={handleDelete}>
<TickIcon />
</button>
</>
)}
</div>
</>
);
}
);
const code = memo((props: CodeProps) => {
const { inline, className, children } = props;
const match = /language-(\w+)/.exec(className || '');
const lang = match && match[1];
if (inline) {
return <code className={className}>{children}</code>;
} else {
return <CodeBlock lang={lang || 'text'} codeChildren={children} />;
}
});
const p = memo(
(
props?: Omit<
DetailedHTMLProps<
HTMLAttributes<HTMLParagraphElement>,
HTMLParagraphElement
>,
'ref'
> &
ReactMarkdownProps
) => {
return <p className='whitespace-pre-wrap'>{props?.children}</p>;
}
);
export default ContentView;

View File

@@ -0,0 +1,244 @@
import React, { memo, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import useStore from '@store/store';
import useSubmit from '@hooks/useSubmit';
import { ChatInterface } from '@type/chat';
import PopupModal from '@components/PopupModal';
import TokenCount from '@components/TokenCount';
import CommandPrompt from '../CommandPrompt';
const EditView = ({
content,
setIsEdit,
messageIndex,
sticky,
}: {
content: string;
setIsEdit: React.Dispatch<React.SetStateAction<boolean>>;
messageIndex: number;
sticky?: boolean;
}) => {
const inputRole = useStore((state) => state.inputRole);
const setChats = useStore((state) => state.setChats);
const currentChatIndex = useStore((state) => state.currentChatIndex);
const [_content, _setContent] = useState<string>(content);
const [isModalOpen, setIsModalOpen] = useState<boolean>(false);
const textareaRef = React.createRef<HTMLTextAreaElement>();
const { t } = useTranslation();
const resetTextAreaHeight = () => {
if (textareaRef.current) textareaRef.current.style.height = 'auto';
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
const isMobile =
/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini|playbook|silk/i.test(
navigator.userAgent
);
if (e.key === 'Enter' && !isMobile && !e.nativeEvent.isComposing) {
const enterToSubmit = useStore.getState().enterToSubmit;
if (sticky) {
if (
(enterToSubmit && !e.shiftKey) ||
(!enterToSubmit && (e.ctrlKey || e.shiftKey))
) {
e.preventDefault();
handleSaveAndSubmit();
resetTextAreaHeight();
}
} else {
if (e.ctrlKey && e.shiftKey) {
e.preventDefault();
handleSaveAndSubmit();
resetTextAreaHeight();
} else if (e.ctrlKey || e.shiftKey) handleSave();
}
}
};
const handleSave = () => {
if (sticky && (_content === '' || useStore.getState().generating)) return;
const updatedChats: ChatInterface[] = JSON.parse(
JSON.stringify(useStore.getState().chats)
);
const updatedMessages = updatedChats[currentChatIndex].messages;
if (sticky) {
updatedMessages.push({ role: inputRole, content: _content });
_setContent('');
resetTextAreaHeight();
} else {
updatedMessages[messageIndex].content = _content;
setIsEdit(false);
}
setChats(updatedChats);
};
const { handleSubmit } = useSubmit();
const handleSaveAndSubmit = () => {
if (useStore.getState().generating) return;
const updatedChats: ChatInterface[] = JSON.parse(
JSON.stringify(useStore.getState().chats)
);
const updatedMessages = updatedChats[currentChatIndex].messages;
if (sticky) {
if (_content !== '') {
updatedMessages.push({ role: inputRole, content: _content });
}
_setContent('');
resetTextAreaHeight();
} else {
updatedMessages[messageIndex].content = _content;
updatedChats[currentChatIndex].messages = updatedMessages.slice(
0,
messageIndex + 1
);
setIsEdit(false);
}
setChats(updatedChats);
handleSubmit();
};
useEffect(() => {
if (textareaRef.current) {
textareaRef.current.style.height = 'auto';
textareaRef.current.style.height = `${textareaRef.current.scrollHeight}px`;
}
}, [_content]);
useEffect(() => {
if (textareaRef.current) {
textareaRef.current.style.height = 'auto';
textareaRef.current.style.height = `${textareaRef.current.scrollHeight}px`;
}
}, []);
return (
<>
<div
className={`w-full ${
sticky
? 'py-2 md:py-3 px-2 md:px-4 border border-black/10 bg-white dark:border-gray-900/50 dark:text-white dark:bg-gray-700 rounded-md shadow-[0_0_10px_rgba(0,0,0,0.10)] dark:shadow-[0_0_15px_rgba(0,0,0,0.10)]'
: ''
}`}
>
<textarea
ref={textareaRef}
className='m-0 resize-none rounded-lg bg-transparent overflow-y-hidden focus:ring-0 focus-visible:ring-0 leading-7 w-full placeholder:text-gray-500/40'
onChange={(e) => {
_setContent(e.target.value);
}}
value={_content}
placeholder={t('submitPlaceholder') as string}
onKeyDown={handleKeyDown}
rows={1}
></textarea>
</div>
<EditViewButtons
sticky={sticky}
handleSaveAndSubmit={handleSaveAndSubmit}
handleSave={handleSave}
setIsModalOpen={setIsModalOpen}
setIsEdit={setIsEdit}
_setContent={_setContent}
/>
{isModalOpen && (
<PopupModal
setIsModalOpen={setIsModalOpen}
title={t('warning') as string}
message={t('clearMessageWarning') as string}
handleConfirm={handleSaveAndSubmit}
/>
)}
</>
);
};
const EditViewButtons = memo(
({
sticky = false,
handleSaveAndSubmit,
handleSave,
setIsModalOpen,
setIsEdit,
_setContent,
}: {
sticky?: boolean;
handleSaveAndSubmit: () => void;
handleSave: () => void;
setIsModalOpen: React.Dispatch<React.SetStateAction<boolean>>;
setIsEdit: React.Dispatch<React.SetStateAction<boolean>>;
_setContent: React.Dispatch<React.SetStateAction<string>>;
}) => {
const { t } = useTranslation();
const generating = useStore.getState().generating;
const advancedMode = useStore((state) => state.advancedMode);
return (
<div className='flex'>
<div className='flex-1 text-center mt-2 flex justify-center'>
{sticky && (
<button
className={`btn relative mr-2 btn-primary ${
generating ? 'cursor-not-allowed opacity-40' : ''
}`}
onClick={handleSaveAndSubmit}
>
<div className='flex items-center justify-center gap-2'>
{t('saveAndSubmit')}
</div>
</button>
)}
<button
className={`btn relative mr-2 ${
sticky
? `btn-neutral ${
generating ? 'cursor-not-allowed opacity-40' : ''
}`
: 'btn-primary'
}`}
onClick={handleSave}
>
<div className='flex items-center justify-center gap-2'>
{t('save')}
</div>
</button>
{sticky || (
<button
className='btn relative mr-2 btn-neutral'
onClick={() => {
!generating && setIsModalOpen(true);
}}
>
<div className='flex items-center justify-center gap-2'>
{t('saveAndSubmit')}
</div>
</button>
)}
{sticky || (
<button
className='btn relative btn-neutral'
onClick={() => setIsEdit(false)}
>
<div className='flex items-center justify-center gap-2'>
{t('cancel')}
</div>
</button>
)}
</div>
{sticky && advancedMode && <TokenCount />}
<CommandPrompt _setContent={_setContent} />
</div>
);
}
);
export default EditView;

View File

@@ -44,6 +44,7 @@ const ConfigMenu = ({
title={t('configuration') as string}
setIsModalOpen={setIsModalOpen}
handleConfirm={handleConfirm}
handleClickBackdrop={handleConfirm}
>
<div className='p-6 border-b border-gray-200 dark:border-gray-600'>
<ModelSelector _model={_model} _setModel={_setModel} />

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-2 px-2 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

@@ -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 { v4 as uuidv4 } from 'uuid';
import useStore from '@store/store';
import ExportIcon from '@icon/ExportIcon';
import downloadFile from '@utils/downloadFile';
import { getToday } from '@utils/date';
import PopupModal from '@components/PopupModal';
import {
isLegacyImport,
validateAndFixChats,
validateExportV1,
} from '@utils/import';
import { ChatInterface, Folder, FolderCollection } from '@type/chat';
import Export, { ExportBase, ExportV1 } from '@type/export';
import ImportChat from './ImportChat';
import ExportChat from './ExportChat';
import ImportChatOpenAI from './ImportChatOpenAI';
const ImportExportChat = () => {
const { t } = useTranslation();
@@ -22,7 +15,7 @@ const ImportExportChat = () => {
return (
<>
<a
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'
className='flex py-2 px-2 items-center gap-3 rounded-md hover:bg-gray-500/10 transition-colors duration-200 text-white cursor-pointer text-sm'
onClick={() => {
setIsModalOpen(true);
}}
@@ -39,6 +32,8 @@ const ImportExportChat = () => {
<div className='p-6 border-b border-gray-200 dark:border-gray-600'>
<ImportChat />
<ExportChat />
<div className='border-t my-3 border-gray-200 dark:border-gray-600' />
<ImportChatOpenAI setIsModalOpen={setIsModalOpen} />
</div>
</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;

View File

@@ -20,6 +20,8 @@ import RefreshIcon from '@icon/RefreshIcon';
import { folderColorOptions } from '@constants/color';
import useHideOnOutsideClick from '@hooks/useHideOnOutsideClick';
const ChatFolder = ({
folderChats,
folderId,
@@ -27,9 +29,9 @@ const ChatFolder = ({
folderChats: ChatHistoryInterface[];
folderId: string;
}) => {
const folderName = useStore((state) => state.folders[folderId].name);
const isExpanded = useStore((state) => state.folders[folderId].expanded);
const color = useStore((state) => state.folders[folderId].color);
const folderName = useStore((state) => state.folders[folderId]?.name);
const isExpanded = useStore((state) => state.folders[folderId]?.expanded);
const color = useStore((state) => state.folders[folderId]?.color);
const setChats = useStore((state) => state.setChats);
const setFolders = useStore((state) => state.setFolders);
@@ -42,7 +44,8 @@ const ChatFolder = ({
const [isEdit, setIsEdit] = useState<boolean>(false);
const [isDelete, setIsDelete] = useState<boolean>(false);
const [isHover, setIsHover] = useState<boolean>(false);
const [showPalette, setShowPalette] = useState<boolean>(false);
const [showPalette, setShowPalette, paletteRef] = useHideOnOutsideClick();
const editTitle = () => {
const updatedFolders: FolderCollection = JSON.parse(
@@ -146,7 +149,9 @@ const ChatFolder = ({
return (
<div
className={`w-full transition-colors ${isHover ? 'bg-gray-800/40' : ''}`}
className={`w-full transition-colors group/folder ${
isHover ? 'bg-gray-800/40' : ''
}`}
onDrop={handleDrop}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
@@ -155,7 +160,7 @@ const ChatFolder = ({
style={{ background: color || '' }}
className={`${
color ? '' : 'hover:bg-gray-850'
} transition-colors flex py-3 pl-3 pr-1 items-center gap-3 relative rounded-md break-all cursor-pointer parent-sibling`}
} transition-colors flex py-2 pl-2 pr-1 items-center gap-3 relative rounded-md break-all cursor-pointer parent-sibling`}
onClick={toggleExpanded}
ref={folderRef}
onMouseEnter={() => {
@@ -215,7 +220,10 @@ const ChatFolder = ({
</>
) : (
<>
<div className='relative'>
<div
className='relative md:hidden group-hover/folder:md:inline'
ref={paletteRef}
>
<button
className='p-1 hover:text-white'
onClick={() => {
@@ -250,13 +258,13 @@ const ChatFolder = ({
</div>
<button
className='p-1 hover:text-white'
className='p-1 hover:text-white md:hidden group-hover/folder:md:inline'
onClick={() => setIsEdit(true)}
>
<EditIcon />
</button>
<button
className='p-1 hover:text-white'
className='p-1 hover:text-white md:hidden group-hover/folder:md:inline'
onClick={() => setIsDelete(true)}
>
<DeleteIcon />

View File

@@ -11,9 +11,9 @@ import useStore from '@store/store';
const ChatHistoryClass = {
normal:
'flex py-3 px-3 items-center gap-3 relative rounded-md bg-gray-900 hover:bg-gray-850 break-all hover:pr-4 group transition-opacity',
'flex py-2 px-2 items-center gap-3 relative rounded-md bg-gray-900 hover:bg-gray-850 break-all hover:pr-4 group transition-opacity',
active:
'flex py-3 px-3 items-center gap-3 relative rounded-md break-all pr-14 bg-gray-800 hover:bg-gray-800 group transition-opacity',
'flex py-2 px-2 items-center gap-3 relative rounded-md break-all pr-14 bg-gray-800 hover:bg-gray-800 group transition-opacity',
normalGradient:
'absolute inset-y-0 right-0 w-8 z-10 bg-gradient-to-l from-gray-900 group-hover:from-gray-850',
activeGradient:

View File

@@ -2,7 +2,6 @@ import React, { useEffect, useRef, useState } from 'react';
import useStore from '@store/store';
import { shallow } from 'zustand/shallow';
import NewFolder from './NewFolder';
import ChatFolder from './ChatFolder';
import ChatHistory from './ChatHistory';
import ChatSearch from './ChatSearch';
@@ -164,7 +163,6 @@ const ChatHistoryList = () => {
onDragLeave={handleDragLeave}
onDragEnd={handleDragEnd}
>
<NewFolder />
<ChatSearch filter={filter} setFilter={setFilter} />
<div className='flex flex-col gap-2 text-gray-100 text-sm'>
{Object.keys(chatFolders).map((folderId) => (

View File

@@ -3,6 +3,7 @@ import React, { useEffect, useRef } from 'react';
import useStore from '@store/store';
import NewChat from './NewChat';
import NewFolder from './NewFolder';
import ChatHistoryList from './ChatHistoryList';
import MenuOptions from './MenuOptions';
@@ -38,7 +39,10 @@ const Menu = () => {
<div className='flex h-full min-h-0 flex-col'>
<div className='flex h-full w-full flex-1 items-start border-white/20'>
<nav className='flex h-full flex-1 flex-col space-y-1 px-2 pt-2'>
<NewChat />
<div className='flex gap-2'>
<NewChat />
<NewFolder />
</div>
<ChatHistoryList />
<MenuOptions />
</nav>

View File

@@ -11,7 +11,7 @@ const Config = () => {
return (
<>
<a
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'
className='flex py-2 px-2 items-center gap-3 rounded-md hover:bg-gray-500/10 transition-colors duration-200 text-white cursor-pointer text-sm'
id='api-menu'
onClick={() => setIsModalOpen(true)}
>

View File

@@ -21,15 +21,14 @@ const ClearConversation = () => {
return (
<>
<a
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'
<button className='btn btn-neutral'
onClick={() => {
setIsModalOpen(true);
}}
>
<DeleteIcon />
{t('clearConversation')}
</a>
</button>
{isModalOpen && (
<PopupModal
setIsModalOpen={setIsModalOpen}

View File

@@ -7,7 +7,7 @@ const Me = () => {
const { t } = useTranslation();
return (
<a
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'
className='flex py-2 px-2 items-center gap-3 rounded-md hover:bg-gray-500/10 transition-colors duration-200 text-white cursor-pointer text-sm'
href='https://github.com/ztjhz/BetterChatGPT'
target='_blank'
>

View File

@@ -1,16 +1,20 @@
import React from 'react';
import useStore from '@store/store';
import ClearConversation from './ClearConversation';
import Api from './Api';
import Me from './Me';
import AboutMenu from '@components/AboutMenu';
import ImportExportChat from '@components/ImportExportChat';
import SettingsMenu from '@components/SettingsMenu';
import CollapseOptions from './CollapseOptions';
import GoogleSync from '@components/GoogleSync';
import { TotalTokenCostDisplay } from '@components/SettingsMenu/TotalTokenCost';
const googleClientId = import.meta.env.VITE_GOOGLE_CLIENT_ID || undefined;
const MenuOptions = () => {
const hideMenuOptions = useStore((state) => state.hideMenuOptions);
const countTotalTokens = useStore((state) => state.countTotalTokens);
return (
<>
<CollapseOptions />
@@ -19,8 +23,9 @@ const MenuOptions = () => {
hideMenuOptions ? 'max-h-0' : 'max-h-full'
} overflow-hidden transition-all`}
>
{countTotalTokens && <TotalTokenCostDisplay />}
{googleClientId && <GoogleSync clientId={googleClientId} />}
<AboutMenu />
<ClearConversation />
<ImportExportChat />
<Api />
<SettingsMenu />

View File

@@ -13,14 +13,12 @@ const NewChat = ({ folder }: { folder?: string }) => {
return (
<a
className={`flex items-center max-md:hidden rounded-md hover:bg-gray-500/10 transition-all duration-200 text-white text-sm flex-shrink-0 ${
className={`flex flex-1 items-center rounded-md hover:bg-gray-500/10 transition-all duration-200 text-white text-sm flex-shrink-0 ${
generating
? 'cursor-not-allowed opacity-40'
: 'cursor-pointer opacity-100'
} ${
folder
? 'justify-start'
: 'py-3 px-3 gap-3 md:mb-2 md:border md:border-white/20'
folder ? 'justify-start' : 'py-2 px-2 gap-3 mb-2 border border-white/20'
}`}
onClick={() => {
if (!generating) addChat(folder);
@@ -28,15 +26,13 @@ const NewChat = ({ folder }: { folder?: string }) => {
title={folder ? String(t('newChat')) : ''}
>
{folder ? (
<div className='max-h-0 parent-sibling-hover:max-h-10 hover:max-h-10 parent-sibling-hover:py-3 hover:py-3 px-3 overflow-hidden transition-all duration-200 delay-500 text-sm flex gap-3 items-center text-gray-100'>
<div className='max-h-0 parent-sibling-hover:max-h-10 hover:max-h-10 parent-sibling-hover:py-2 hover:py-2 px-2 overflow-hidden transition-all duration-200 delay-500 text-sm flex gap-3 items-center text-gray-100'>
<PlusIcon /> {t('newChat')}
</div>
) : (
<>
<PlusIcon />
<span className='hidden md:inline-flex text-white text-sm'>
{t('newChat')}
</span>
<span className='inline-flex text-white text-sm'>{t('newChat')}</span>
</>
)}
</a>

View File

@@ -43,7 +43,7 @@ const NewFolder = () => {
return (
<a
className={`max-md:hidden flex py-3 px-3 items-center gap-3 rounded-md hover:bg-gray-500/10 transition-colors duration-200 text-white text-sm md:mb-2 flex-shrink-0 md:border md:border-white/20 transition-opacity ${
className={`flex py-3 px-3 items-center gap-3 rounded-md hover:bg-gray-500/10 transition-colors duration-200 text-white text-sm mb-2 flex-shrink-0 border border-white/20 transition-opacity ${
generating
? 'cursor-not-allowed opacity-40'
: 'cursor-pointer opacity-100'
@@ -52,10 +52,7 @@ const NewFolder = () => {
if (!generating) addFolder();
}}
>
<NewFolderIcon />{' '}
<span className='hidden md:inline-flex text-white text-sm'>
{t('newFolder')}
</span>
<NewFolderIcon />
</a>
);
};

View File

@@ -10,6 +10,7 @@ const PopupModal = ({
setIsModalOpen,
handleConfirm,
handleClose,
handleClickBackdrop,
cancelButton = true,
children,
}: {
@@ -18,6 +19,7 @@ const PopupModal = ({
setIsModalOpen: React.Dispatch<React.SetStateAction<boolean>>;
handleConfirm?: () => void;
handleClose?: () => void;
handleClickBackdrop?: () => void;
cancelButton?: boolean;
children?: React.ReactElement;
}) => {
@@ -29,6 +31,11 @@ const PopupModal = ({
setIsModalOpen(false);
};
const _handleBackdropClose = () => {
if (handleClickBackdrop) handleClickBackdrop();
else _handleClose();
};
if (modalRoot) {
return ReactDOM.createPortal(
<div className='fixed top-0 left-0 z-[999] w-full p-4 overflow-x-hidden overflow-y-auto h-full flex justify-center items-center'>
@@ -81,7 +88,7 @@ const PopupModal = ({
</div>
<div
className='bg-gray-800/90 absolute top-0 left-0 h-full w-full z-[-1]'
onClick={_handleClose}
onClick={_handleBackdropClose}
/>
</div>,
modalRoot

View File

@@ -67,6 +67,10 @@ const PromptLibraryMenuPopUp = ({
_setPrompts(updatedPrompts);
};
const clearPrompts = () => {
_setPrompts([]);
};
const handleOnFocus = (e: React.FocusEvent<HTMLTextAreaElement, Element>) => {
e.target.style.height = 'auto';
e.target.style.height = `${e.target.scrollHeight}px`;
@@ -149,6 +153,14 @@ const PromptLibraryMenuPopUp = ({
<div className='flex justify-center cursor-pointer' onClick={addPrompt}>
<PlusIcon />
</div>
<div className='flex justify-center mt-2'>
<div
className='btn btn-neutral cursor-pointer text-xs'
onClick={clearPrompts}
>
{t('clearPrompts')}
</div>
</div>
<div className='mt-6 px-2'>
{t('morePrompts')}
<a

View File

@@ -19,7 +19,7 @@ const SearchBar = ({
<input
disabled={disabled}
type='text'
className='text-gray-800 dark:text-white p-3 text-sm bg-gray-700 disabled:opacity-40 disabled:cursor-not-allowed transition-opacity m-0 w-full h-full focus:outline-none rounded border-none'
className='text-gray-800 dark:text-white p-3 text-sm bg-transparent disabled:opacity-40 disabled:cursor-not-allowed transition-opacity m-0 w-full h-full focus:outline-none rounded border border-white/20'
placeholder={t('search') as string}
value={value}
onChange={(e) => {

View File

@@ -0,0 +1,28 @@
import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import useStore from '@store/store';
import Toggle from '@components/Toggle';
const AdvancedModeToggle = () => {
const { t } = useTranslation();
const setAdvancedMode = useStore((state) => state.setAdvancedMode);
const [isChecked, setIsChecked] = useState<boolean>(
useStore.getState().advancedMode
);
useEffect(() => {
setAdvancedMode(isChecked);
}, [isChecked]);
return (
<Toggle
label={t('advancedMode') as string}
isChecked={isChecked}
setIsChecked={setIsChecked}
/>
);
};
export default AdvancedModeToggle;

View File

@@ -0,0 +1,28 @@
import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import useStore from '@store/store';
import Toggle from '@components/Toggle';
const InlineLatexToggle = () => {
const { t } = useTranslation();
const setInlineLatex = useStore((state) => state.setInlineLatex);
const [isChecked, setIsChecked] = useState<boolean>(
useStore.getState().inlineLatex
);
useEffect(() => {
setInlineLatex(isChecked);
}, [isChecked]);
return (
<Toggle
label={t('inlineLatex') as string}
isChecked={isChecked}
setIsChecked={setIsChecked}
/>
);
};
export default InlineLatexToggle;

View File

@@ -1,15 +1,21 @@
import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import useStore from '@store/store';
import useCloudAuthStore from '@store/cloud-auth-store';
import PopupModal from '@components/PopupModal';
import SettingIcon from '@icon/SettingIcon';
import ThemeSwitcher from '@components/Menu/MenuOptions/ThemeSwitcher';
import LanguageSelector from '@components/LanguageSelector';
import AutoTitleToggle from './AutoTitleToggle';
import AdvancedModeToggle from './AdvencedModeToggle';
import InlineLatexToggle from './InlineLatexToggle';
import PromptLibraryMenu from '@components/PromptLibraryMenu';
import ChatConfigMenu from '@components/ChatConfigMenu';
import EnterToSubmitToggle from './EnterToSubmitToggle';
import TotalTokenCost, { TotalTokenCostToggle } from './TotalTokenCost';
import ClearConversation from '@components/Menu/MenuOptions/ClearConversation';
const SettingsMenu = () => {
const { t } = useTranslation();
@@ -23,7 +29,7 @@ const SettingsMenu = () => {
return (
<>
<a
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'
className='flex py-2 px-2 items-center gap-3 rounded-md hover:bg-gray-500/10 transition-colors duration-200 text-white cursor-pointer text-sm'
onClick={() => {
setIsModalOpen(true);
}}
@@ -42,9 +48,14 @@ const SettingsMenu = () => {
<div className='flex flex-col gap-3'>
<AutoTitleToggle />
<EnterToSubmitToggle />
<InlineLatexToggle />
<AdvancedModeToggle />
<TotalTokenCostToggle />
</div>
<ClearConversation />
<PromptLibraryMenu />
<ChatConfigMenu />
<TotalTokenCost />
</div>
</PopupModal>
)}

View File

@@ -0,0 +1,136 @@
import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import useStore from '@store/store';
import { modelCost } from '@constants/chat';
import Toggle from '@components/Toggle/Toggle';
import { ModelOptions, TotalTokenUsed } from '@type/chat';
import CalculatorIcon from '@icon/CalculatorIcon';
type CostMapping = { model: string; cost: number }[];
const tokenCostToCost = (
tokenCost: TotalTokenUsed[ModelOptions],
model: ModelOptions
) => {
if (!tokenCost) return 0;
const { prompt, completion } = modelCost[model as keyof typeof modelCost];
const completionCost =
(completion.price / completion.unit) * tokenCost.completionTokens;
const promptCost = (prompt.price / prompt.unit) * tokenCost.promptTokens;
return completionCost + promptCost;
};
const TotalTokenCost = () => {
const { t } = useTranslation(['main', 'model']);
const totalTokenUsed = useStore((state) => state.totalTokenUsed);
const setTotalTokenUsed = useStore((state) => state.setTotalTokenUsed);
const countTotalTokens = useStore((state) => state.countTotalTokens);
const [costMapping, setCostMapping] = useState<CostMapping>([]);
const resetCost = () => {
setTotalTokenUsed({});
};
useEffect(() => {
const updatedCostMapping: CostMapping = [];
Object.entries(totalTokenUsed).forEach(([model, tokenCost]) => {
const cost = tokenCostToCost(tokenCost, model as ModelOptions);
updatedCostMapping.push({ model, cost });
});
setCostMapping(updatedCostMapping);
}, [totalTokenUsed]);
return countTotalTokens ? (
<div className='flex flex-col items-center gap-2'>
<div className='relative overflow-x-auto shadow-md sm:rounded-lg'>
<table className='w-full text-sm text-left text-gray-500 dark:text-gray-400'>
<thead className='text-xs text-gray-700 uppercase bg-gray-50 dark:bg-gray-700 dark:text-gray-400'>
<tr>
<th className='px-4 py-2'>{t('model', { ns: 'model' })}</th>
<th className='px-4 py-2'>USD</th>
</tr>
</thead>
<tbody>
{costMapping.map(({ model, cost }) => (
<tr
key={model}
className='bg-white border-b dark:bg-gray-800 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700'
>
<td className='px-4 py-2'>{model}</td>
<td className='px-4 py-2'>{cost.toPrecision(3)}</td>
</tr>
))}
<tr className='bg-white border-b dark:bg-gray-800 dark:border-gray-700 font-bold'>
<td className='px-4 py-2'>{t('total', { ns: 'main' })}</td>
<td className='px-4 py-2'>
{costMapping
.reduce((prev, curr) => prev + curr.cost, 0)
.toPrecision(3)}
</td>
</tr>
</tbody>
</table>
</div>
<div className='btn btn-neutral cursor-pointer' onClick={resetCost}>
{t('resetCost', { ns: 'main' })}
</div>
</div>
) : (
<></>
);
};
export const TotalTokenCostToggle = () => {
const { t } = useTranslation('main');
const setCountTotalTokens = useStore((state) => state.setCountTotalTokens);
const [isChecked, setIsChecked] = useState<boolean>(
useStore.getState().countTotalTokens
);
useEffect(() => {
setCountTotalTokens(isChecked);
}, [isChecked]);
return (
<Toggle
label={t('countTotalTokens') as string}
isChecked={isChecked}
setIsChecked={setIsChecked}
/>
);
};
export const TotalTokenCostDisplay = () => {
const { t } = useTranslation();
const totalTokenUsed = useStore((state) => state.totalTokenUsed);
const [totalCost, setTotalCost] = useState<number>(0);
useEffect(() => {
let updatedTotalCost = 0;
Object.entries(totalTokenUsed).forEach(([model, tokenCost]) => {
updatedTotalCost += tokenCostToCost(tokenCost, model as ModelOptions);
});
setTotalCost(updatedTotalCost);
}, [totalTokenUsed]);
return (
<a className='flex py-2 px-2 items-center gap-3 rounded-md hover:bg-gray-500/10 transition-colors duration-200 text-white text-sm'>
<CalculatorIcon />
{`USD ${totalCost.toPrecision(3)}`}
</a>
);
};
export default TotalTokenCost;

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

@@ -21,7 +21,9 @@ const TokenCount = React.memo(() => {
);
const cost = useMemo(() => {
const price = modelCost[model].price * (tokenCount / modelCost[model].unit);
const price =
modelCost[model].prompt.price *
(tokenCount / modelCost[model].prompt.unit);
return price.toPrecision(3);
}, [model, tokenCount]);

View File

@@ -38,12 +38,30 @@ export const modelMaxToken = {
};
export const modelCost = {
'gpt-3.5-turbo': { price: 0.002, unit: 1000 },
'gpt-3.5-turbo-0301': { price: 0.002, unit: 1000 },
'gpt-4': { price: 0.03, unit: 1000 },
'gpt-4-0314': { price: 0.03, unit: 1000 },
'gpt-4-32k': { price: 0.06, unit: 1000 },
'gpt-4-32k-0314': { price: 0.06, unit: 1000 },
'gpt-3.5-turbo': {
prompt: { price: 0.002, unit: 1000 },
completion: { price: 0.002, unit: 1000 },
},
'gpt-3.5-turbo-0301': {
prompt: { price: 0.002, unit: 1000 },
completion: { price: 0.002, unit: 1000 },
},
'gpt-4': {
prompt: { price: 0.03, unit: 1000 },
completion: { price: 0.06, unit: 1000 },
},
'gpt-4-0314': {
prompt: { price: 0.03, unit: 1000 },
completion: { price: 0.06, unit: 1000 },
},
'gpt-4-32k': {
prompt: { price: 0.06, unit: 1000 },
completion: { price: 0.12, unit: 1000 },
},
'gpt-4-32k-0314': {
prompt: { price: 0.06, unit: 1000 },
completion: { price: 0.12, unit: 1000 },
},
};
export const defaultUserMaxToken = 4000;
@@ -57,7 +75,10 @@ export const _defaultChatConfig: ConfigInterface = {
frequency_penalty: 0,
};
export const generateDefaultChat = (title?: string, folder?: string): ChatInterface => ({
export const generateDefaultChat = (
title?: string,
folder?: string
): ChatInterface => ({
id: uuidv4(),
title: title ? title : 'New Chat',
messages:
@@ -66,7 +87,7 @@ export const generateDefaultChat = (title?: string, folder?: string): ChatInterf
: [],
config: { ...useStore.getState().defaultChatConfig },
titleSet: false,
folder
folder,
});
export const codeLanguageSubset = [

View File

@@ -0,0 +1,36 @@
import React, { useEffect, useRef, useState } from 'react';
const useHideOnOutsideClick = (): [
boolean,
React.Dispatch<React.SetStateAction<boolean>>,
React.RefObject<HTMLDivElement>
] => {
const elementRef = useRef<HTMLDivElement>(null);
const [showElement, setShowElement] = useState<boolean>(false);
const handleClickOutside = (event: MouseEvent) => {
if (
elementRef.current &&
!elementRef.current.contains(event.target as Node)
) {
setShowElement(false);
}
};
useEffect(() => {
// Bind the event listener only if the element is show.
if (showElement) {
document.addEventListener('mousedown', handleClickOutside);
} else {
document.removeEventListener('mousedown', handleClickOutside);
}
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [showElement, elementRef]);
return [showElement, setShowElement, elementRef];
};
export default useHideOnOutsideClick;

View File

@@ -4,7 +4,7 @@ import { useTranslation } from 'react-i18next';
import { ChatInterface, MessageInterface } from '@type/chat';
import { getChatCompletion, getChatCompletionStream } from '@api/api';
import { parseEventSource } from '@api/helper';
import { limitMessageTokens } from '@utils/messageUtils';
import { limitMessageTokens, updateTotalTokenUsed } from '@utils/messageUtils';
import { _defaultChatConfig } from '@constants/chat';
import { officialAPIEndpoint } from '@constants/auth';
@@ -141,8 +141,21 @@ const useSubmit = () => {
stream.cancel();
}
// generate title for new chats
// update tokens used in chatting
const currChats = useStore.getState().chats;
const countTotalTokens = useStore.getState().countTotalTokens;
if (currChats && countTotalTokens) {
const model = currChats[currentChatIndex].config.model;
const messages = currChats[currentChatIndex].messages;
updateTotalTokenUsed(
model,
messages.slice(0, -1),
messages[messages.length - 1]
);
}
// generate title for new chats
if (
useStore.getState().autoTitle &&
currChats &&
@@ -169,6 +182,15 @@ const useSubmit = () => {
updatedChats[currentChatIndex].title = title;
updatedChats[currentChatIndex].titleSet = true;
setChats(updatedChats);
// update tokens used for generating title
if (countTotalTokens) {
const model = currChats[currentChatIndex].config.model;
updateTotalTokenUsed(model, [message], {
role: 'assistant',
content: title,
});
}
}
} catch (e: unknown) {
const err = (e as Error).message;

View File

@@ -4,6 +4,32 @@ import { initReactI18next } from 'react-i18next';
import Backend from 'i18next-http-backend';
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
.use(Backend)
.use(LanguageDetector)
@@ -15,7 +41,7 @@ i18n
fallbackLng: {
default: ['en'],
},
ns: ['main', 'api', 'about', 'model'],
ns: namespace,
defaultNS: 'main',
});

View File

@@ -2,6 +2,7 @@ import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import './main.css';
await import('katex/dist/katex.min.css');
import './i18n';

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

@@ -1,6 +1,6 @@
import { StoreSlice } from './store';
import { Theme } from '@type/theme';
import { ConfigInterface } from '@type/chat';
import { ConfigInterface, TotalTokenUsed } from '@type/chat';
import { _defaultChatConfig, _defaultSystemMessage } from '@constants/chat';
export interface ConfigSlice {
@@ -8,18 +8,28 @@ export interface ConfigSlice {
theme: Theme;
autoTitle: boolean;
hideMenuOptions: boolean;
advancedMode: boolean;
defaultChatConfig: ConfigInterface;
defaultSystemMessage: string;
hideSideMenu: boolean;
enterToSubmit: boolean;
inlineLatex: boolean;
markdownMode: boolean;
countTotalTokens: boolean;
totalTokenUsed: TotalTokenUsed;
setOpenConfig: (openConfig: boolean) => void;
setTheme: (theme: Theme) => void;
setAutoTitle: (autoTitle: boolean) => void;
setAdvancedMode: (advancedMode: boolean) => void;
setDefaultChatConfig: (defaultChatConfig: ConfigInterface) => void;
setDefaultSystemMessage: (defaultSystemMessage: string) => void;
setHideMenuOptions: (hideMenuOptions: boolean) => void;
setHideSideMenu: (hideSideMenu: boolean) => void;
setEnterToSubmit: (enterToSubmit: boolean) => void;
setInlineLatex: (inlineLatex: boolean) => void;
setMarkdownMode: (markdownMode: boolean) => void;
setCountTotalTokens: (countTotalTokens: boolean) => void;
setTotalTokenUsed: (totalTokenUsed: TotalTokenUsed) => void;
}
export const createConfigSlice: StoreSlice<ConfigSlice> = (set, get) => ({
@@ -29,8 +39,13 @@ export const createConfigSlice: StoreSlice<ConfigSlice> = (set, get) => ({
hideSideMenu: false,
autoTitle: false,
enterToSubmit: true,
advancedMode: true,
defaultChatConfig: _defaultChatConfig,
defaultSystemMessage: _defaultSystemMessage,
inlineLatex: false,
markdownMode: true,
countTotalTokens: false,
totalTokenUsed: {},
setOpenConfig: (openConfig: boolean) => {
set((prev: ConfigSlice) => ({
...prev,
@@ -49,6 +64,12 @@ export const createConfigSlice: StoreSlice<ConfigSlice> = (set, get) => ({
autoTitle: autoTitle,
}));
},
setAdvancedMode: (advancedMode: boolean) => {
set((prev: ConfigSlice) => ({
...prev,
advancedMode: advancedMode,
}));
},
setDefaultChatConfig: (defaultChatConfig: ConfigInterface) => {
set((prev: ConfigSlice) => ({
...prev,
@@ -79,4 +100,28 @@ export const createConfigSlice: StoreSlice<ConfigSlice> = (set, get) => ({
enterToSubmit: enterToSubmit,
}));
},
setInlineLatex: (inlineLatex: boolean) => {
set((prev: ConfigSlice) => ({
...prev,
inlineLatex: inlineLatex,
}));
},
setMarkdownMode: (markdownMode: boolean) => {
set((prev: ConfigSlice) => ({
...prev,
markdownMode: markdownMode,
}));
},
setCountTotalTokens: (countTotalTokens: boolean) => {
set((prev: ConfigSlice) => ({
...prev,
countTotalTokens: countTotalTokens,
}));
},
setTotalTokenUsed: (totalTokenUsed: TotalTokenUsed) => {
set((prev: ConfigSlice) => ({
...prev,
totalTokenUsed: totalTokenUsed,
}));
},
});

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;

Some files were not shown because too many files have changed in this diff Show More