mirror of
https://github.com/ztjhz/BetterChatGPT.git
synced 2023-07-20 23:11:29 +03:00
Merge branch 'main' into persian
This commit is contained in:
@@ -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
|
||||
8
.github/workflows/deploy.yml
vendored
8
.github/workflows/deploy.yml
vendored
@@ -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
|
||||
|
||||
1
.github/workflows/publish.yml
vendored
1
.github/workflows/publish.yml
vendored
@@ -27,3 +27,4 @@ jobs:
|
||||
run: yarn make
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
VITE_GOOGLE_CLIENT_ID: ${{ secrets.GCLIENT }}
|
||||
|
||||
@@ -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 | [](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 | [](https://github.com/sponsors/ztjhz) |
|
||||
| KoFi | [](https://ko-fi.com/betterchatgpt) |
|
||||
|
||||
感谢您成为我们社区的一员,我们期待着在未来为您提供更好的服务。
|
||||
|
||||
16
README.md
16
README.md
@@ -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 | [](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 | [](https://github.com/sponsors/ztjhz) |
|
||||
| KoFi | [](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.
|
||||
|
||||
@@ -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}/`);
|
||||
});
|
||||
};
|
||||
|
||||
13
package.json
13
package.json
@@ -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
BIN
public/icon-rounded.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 51 KiB |
16
public/locales/da/drive.json
Normal file
16
public/locales/da/drive.json
Normal 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"
|
||||
}
|
||||
}
|
||||
@@ -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..."
|
||||
}
|
||||
|
||||
16
public/locales/en-US/drive.json
Normal file
16
public/locales/en-US/drive.json
Normal 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"
|
||||
}
|
||||
}
|
||||
@@ -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..."
|
||||
}
|
||||
|
||||
16
public/locales/en/drive.json
Normal file
16
public/locales/en/drive.json
Normal 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"
|
||||
}
|
||||
}
|
||||
@@ -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..."
|
||||
}
|
||||
|
||||
16
public/locales/es/drive.json
Normal file
16
public/locales/es/drive.json
Normal 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"
|
||||
}
|
||||
}
|
||||
@@ -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..."
|
||||
}
|
||||
|
||||
16
public/locales/fr/drive.json
Normal file
16
public/locales/fr/drive.json
Normal 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"
|
||||
}
|
||||
}
|
||||
@@ -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..."
|
||||
}
|
||||
|
||||
16
public/locales/it/drive.json
Normal file
16
public/locales/it/drive.json
Normal 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"
|
||||
}
|
||||
}
|
||||
@@ -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..."
|
||||
}
|
||||
|
||||
16
public/locales/ja/drive.json
Normal file
16
public/locales/ja/drive.json
Normal 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"
|
||||
}
|
||||
}
|
||||
@@ -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": "メッセージを入力するか、[/] をクリックしてプロンプトを表示します..."
|
||||
}
|
||||
|
||||
16
public/locales/ms/drive.json
Normal file
16
public/locales/ms/drive.json
Normal 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"
|
||||
}
|
||||
}
|
||||
@@ -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..."
|
||||
}
|
||||
|
||||
16
public/locales/nb/drive.json
Normal file
16
public/locales/nb/drive.json
Normal 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"
|
||||
}
|
||||
}
|
||||
@@ -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..."
|
||||
}
|
||||
|
||||
16
public/locales/sv/drive.json
Normal file
16
public/locales/sv/drive.json
Normal 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"
|
||||
}
|
||||
}
|
||||
@@ -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..."
|
||||
}
|
||||
|
||||
16
public/locales/zh-CN/drive.json
Normal file
16
public/locales/zh-CN/drive.json
Normal 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"
|
||||
}
|
||||
}
|
||||
@@ -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": "输入消息或点击 [/] 以使用提示词…"
|
||||
}
|
||||
|
||||
16
public/locales/zh-HK/drive.json
Normal file
16
public/locales/zh-HK/drive.json
Normal 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"
|
||||
}
|
||||
}
|
||||
@@ -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": "輸入消息或點擊 [/] 以使用提示詞…"
|
||||
}
|
||||
|
||||
16
public/locales/zh-TW/drive.json
Normal file
16
public/locales/zh-TW/drive.json
Normal 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"
|
||||
}
|
||||
}
|
||||
@@ -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": "輸入消息或點擊 [/] 以使用提示詞…"
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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
191
src/api/google-api.ts
Normal 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
|
||||
);
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
|
||||
17
src/assets/icons/CalculatorIcon.tsx
Normal file
17
src/assets/icons/CalculatorIcon.tsx
Normal 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;
|
||||
17
src/assets/icons/FileTextIcon.tsx
Normal file
17
src/assets/icons/FileTextIcon.tsx
Normal 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;
|
||||
17
src/assets/icons/GoogleIcon.tsx
Normal file
17
src/assets/icons/GoogleIcon.tsx
Normal 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;
|
||||
18
src/assets/icons/MoneyIcon.tsx
Normal file
18
src/assets/icons/MoneyIcon.tsx
Normal 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;
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
203
src/components/Chat/ChatContent/Message/View/ContentView.tsx
Normal file
203
src/components/Chat/ChatContent/Message/View/ContentView.tsx
Normal 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;
|
||||
244
src/components/Chat/ChatContent/Message/View/EditView.tsx
Normal file
244
src/components/Chat/ChatContent/Message/View/EditView.tsx
Normal 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;
|
||||
@@ -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} />
|
||||
|
||||
379
src/components/GoogleSync/GoogleSync.tsx
Normal file
379
src/components/GoogleSync/GoogleSync.tsx
Normal 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;
|
||||
67
src/components/GoogleSync/GoogleSyncButton.tsx
Normal file
67
src/components/GoogleSync/GoogleSyncButton.tsx
Normal 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;
|
||||
1
src/components/GoogleSync/index.ts
Normal file
1
src/components/GoogleSync/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from './GoogleSync';
|
||||
35
src/components/ImportExportChat/ExportChat.tsx
Normal file
35
src/components/ImportExportChat/ExportChat.tsx
Normal 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;
|
||||
173
src/components/ImportExportChat/ImportChat.tsx
Normal file
173
src/components/ImportExportChat/ImportChat.tsx
Normal 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;
|
||||
78
src/components/ImportExportChat/ImportChatOpenAI.tsx
Normal file
78
src/components/ImportExportChat/ImportChatOpenAI.tsx
Normal 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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)}
|
||||
>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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'
|
||||
>
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
28
src/components/SettingsMenu/AdvencedModeToggle.tsx
Normal file
28
src/components/SettingsMenu/AdvencedModeToggle.tsx
Normal 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;
|
||||
28
src/components/SettingsMenu/InlineLatexToggle.tsx
Normal file
28
src/components/SettingsMenu/InlineLatexToggle.tsx
Normal 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;
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
136
src/components/SettingsMenu/TotalTokenCost.tsx
Normal file
136
src/components/SettingsMenu/TotalTokenCost.tsx
Normal 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;
|
||||
134
src/components/Toast/Toast.tsx
Normal file
134
src/components/Toast/Toast.tsx
Normal 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;
|
||||
1
src/components/Toast/index.ts
Normal file
1
src/components/Toast/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from './Toast';
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
36
src/hooks/useHideOnOutsideClick.ts
Normal file
36
src/hooks/useHideOnOutsideClick.ts
Normal 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;
|
||||
@@ -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;
|
||||
|
||||
28
src/i18n.ts
28
src/i18n.ts
@@ -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',
|
||||
});
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
50
src/store/cloud-auth-slice.ts
Normal file
50
src/store/cloud-auth-slice.ts
Normal 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,
|
||||
}));
|
||||
},
|
||||
});
|
||||
28
src/store/cloud-auth-store.ts
Normal file
28
src/store/cloud-auth-store.ts
Normal 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;
|
||||
@@ -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,
|
||||
}));
|
||||
},
|
||||
});
|
||||
|
||||
72
src/store/storage/GoogleCloudStorage.ts
Normal file
72
src/store/storage/GoogleCloudStorage.ts
Normal 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
Reference in New Issue
Block a user