Compare commits
280 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e8e512a451 | ||
|
|
c2967eafa4 | ||
|
|
853b4dfd49 | ||
|
|
e49adaa314 | ||
|
|
394cdcd9f4 | ||
|
|
c5561434c8 | ||
|
|
26e7a715e0 | ||
|
|
b07b74ba54 | ||
|
|
70152174b9 | ||
|
|
8bd29f6d98 | ||
|
|
b8720eec3d | ||
|
|
ad0f2408c8 | ||
|
|
e2ad9accbe | ||
|
|
b496174b4c | ||
|
|
3295eb806c | ||
|
|
34bef48e84 | ||
|
|
a6c93ad681 | ||
|
|
89ab74a913 | ||
|
|
a46ec62532 | ||
|
|
22a967927f | ||
|
|
0aa7581358 | ||
|
|
513cd28528 | ||
|
|
83b88bd759 | ||
|
|
4f18a471b0 | ||
|
|
737abd54ac | ||
|
|
1de51467ec | ||
|
|
dcc0ab98e1 | ||
|
|
730256dcda | ||
|
|
40e793477b | ||
|
|
b856db4772 | ||
|
|
cab1cbceab | ||
|
|
b73be0dcfa | ||
|
|
c6fb3018e7 | ||
|
|
95cf27ee3e | ||
|
|
7afe09fa02 | ||
|
|
bff33c79b3 | ||
|
|
f73936e5f4 | ||
|
|
2b8d37c38f | ||
|
|
ca3da2505a | ||
|
|
d3046dca07 | ||
|
|
25fd39c2b9 | ||
|
|
ab8724b568 | ||
|
|
c240d14864 | ||
|
|
1d464fdcfa | ||
|
|
aacc292522 | ||
|
|
350a1bbae0 | ||
|
|
90b74aff2e | ||
|
|
37f36ec44a | ||
|
|
71b7eaa3f5 | ||
|
|
719413f87a | ||
|
|
97634865eb | ||
|
|
1dbfb0dab7 | ||
|
|
0a671849b5 | ||
|
|
655e7ce6d6 | ||
|
|
68979015c1 | ||
|
|
8f58c95452 | ||
|
|
67161c983f | ||
|
|
73449d9ec6 | ||
|
|
f5d102b7bd | ||
|
|
40ed6fa9ec | ||
|
|
5164cf46ac | ||
|
|
277685c218 | ||
|
|
e25aa74d7b | ||
|
|
47a6cfcafd | ||
|
|
83a96706b4 | ||
|
|
75be4d9722 | ||
|
|
a5cf2f9148 | ||
|
|
8be19f9982 | ||
|
|
36f3d37ecc | ||
|
|
c233cc0d5c | ||
|
|
194051e424 | ||
|
|
94c0fbb525 | ||
|
|
97a6cd801b | ||
|
|
1041146fcb | ||
|
|
e531a17e0f | ||
|
|
30a7a80bfc | ||
|
|
67f8374c9e | ||
|
|
0cc4aea204 | ||
|
|
04796824d5 | ||
|
|
9020239e1f | ||
|
|
0a12b47760 | ||
|
|
9358a4fdb5 | ||
|
|
7d796f2c3e | ||
|
|
0a1651f6a1 | ||
|
|
d13315c45b | ||
|
|
0b75d5d6fe | ||
|
|
39819b744c | ||
|
|
28c8f066d9 | ||
|
|
c85602b93b | ||
|
|
08c91871c7 | ||
|
|
80ca3bc375 | ||
|
|
0405206438 | ||
|
|
0af8f6a699 | ||
|
|
b0936fa322 | ||
|
|
4cd0ff2682 | ||
|
|
4ce60537ca | ||
|
|
0b47218cd5 | ||
|
|
d56aa2edef | ||
|
|
4e6168d8fa | ||
|
|
4197a92609 | ||
|
|
da42d6272a | ||
|
|
b97594c000 | ||
|
|
0f54ffd8b4 | ||
|
|
610cba4a60 | ||
|
|
4f5ee8b198 | ||
|
|
586c162404 | ||
|
|
1308ef1394 | ||
|
|
1513c27f7d | ||
|
|
0ff3bbb28f | ||
|
|
7987c0100c | ||
|
|
e8611a1d07 | ||
|
|
ce78123369 | ||
|
|
a90db1f1a4 | ||
|
|
a213868b17 | ||
|
|
6b2a2bb858 | ||
|
|
fea3afa740 | ||
|
|
7372b37fe6 | ||
|
|
e11ce141d7 | ||
|
|
46fbd3b66a | ||
|
|
ce3f03267a | ||
|
|
9a2392e4d5 | ||
|
|
1eab4d240d | ||
|
|
5568a60174 | ||
|
|
ea4180f22a | ||
|
|
6d2f3361d0 | ||
|
|
9995a159aa | ||
|
|
854f1c3572 | ||
|
|
dcc13daf67 | ||
|
|
2310bab348 | ||
|
|
d64edfdc7d | ||
|
|
a8c53f1f0d | ||
|
|
ef9f1ee1cf | ||
|
|
66ad54168a | ||
|
|
0891566d1e | ||
|
|
e3b0cb7db7 | ||
|
|
c27554ed2e | ||
|
|
87f793f1c4 | ||
|
|
4078c5283b | ||
|
|
5cac7e48f0 | ||
|
|
7a08c77850 | ||
|
|
1fe9e29187 | ||
|
|
ba8692dbe4 | ||
|
|
d30b406c4c | ||
|
|
915cda70ef | ||
|
|
df19595c5b | ||
|
|
867b3073d4 | ||
|
|
e4e28dbbe2 | ||
|
|
cdbc0e21e7 | ||
|
|
b6f7f95709 | ||
|
|
131af50034 | ||
|
|
23c050b54e | ||
|
|
7442294c41 | ||
|
|
a47dbe6262 | ||
|
|
aabb19656e | ||
|
|
b0284b6974 | ||
|
|
62d88380e0 | ||
|
|
41f351786f | ||
|
|
ffcfb69dee | ||
|
|
c91ce36227 | ||
|
|
d06e58f043 | ||
|
|
d052d221dc | ||
|
|
ff45511011 | ||
|
|
8c6340aed0 | ||
|
|
84b104e65f | ||
|
|
a0c94715ce | ||
|
|
a8aad30fc8 | ||
|
|
2fd50c99b8 | ||
|
|
96ca783517 | ||
|
|
45ca0a8713 | ||
|
|
5d0b849930 | ||
|
|
54aa9debb4 | ||
|
|
4e91437049 | ||
|
|
918f2fecb6 | ||
|
|
796d8031e8 | ||
|
|
6e32f71565 | ||
|
|
6192c2964e | ||
|
|
626a8fbd8e | ||
|
|
a8344ec5bf | ||
|
|
c230fe41f4 | ||
|
|
d0ef0f84c8 | ||
|
|
8289558d94 | ||
|
|
2e20b28c4d | ||
|
|
9a17e94f8f | ||
|
|
71fc86b9a6 | ||
|
|
8882432210 | ||
|
|
644f3f716f | ||
|
|
8b00805d24 | ||
|
|
d73375958b | ||
|
|
7168498543 | ||
|
|
27515cb00a | ||
|
|
3e7ce67609 | ||
|
|
4fd05e15b4 | ||
|
|
0fa19bb6ad | ||
|
|
d9e5464b3b | ||
|
|
953c5fc970 | ||
|
|
2afbc5883f | ||
|
|
953f846958 | ||
|
|
a4d5f6a3f2 | ||
|
|
4a39965b22 | ||
|
|
90dc171b34 | ||
|
|
0e98cb4206 | ||
|
|
9f8e9cb091 | ||
|
|
8773878be2 | ||
|
|
5a409ccfa6 | ||
|
|
0ed8a40a41 | ||
|
|
be71140dd4 | ||
|
|
6d51ec3e37 | ||
|
|
bdfc895800 | ||
|
|
b9975ac283 | ||
|
|
dd1f74da72 | ||
|
|
5d65427a6e | ||
|
|
6a16d31f26 | ||
|
|
8affbfdb4a | ||
|
|
70d59c5b3c | ||
|
|
73979ee67f | ||
|
|
8e513d83a5 | ||
|
|
9bc85ea83d | ||
|
|
943eb5c74d | ||
|
|
bac37cfe36 | ||
|
|
b2334054da | ||
|
|
a705e907ff | ||
|
|
c8bb6b13bf | ||
|
|
50a17bc1b8 | ||
|
|
3038015a5b | ||
|
|
20da895ee7 | ||
|
|
8762765dd0 | ||
|
|
f1aabfa543 | ||
|
|
23de688bf3 | ||
|
|
79f050bac7 | ||
|
|
afae13eec6 | ||
|
|
4fa599e97a | ||
|
|
3b1d6aaa79 | ||
|
|
339230cd74 | ||
|
|
95bad60f7d | ||
|
|
950b8f3f1e | ||
|
|
0e2e5b8393 | ||
|
|
d2cb9957fe | ||
|
|
78b4004ead | ||
|
|
8624062488 | ||
|
|
070fee2ece | ||
|
|
b1569450fd | ||
|
|
09659be002 | ||
|
|
5e4fa09dcb | ||
|
|
16c9589058 | ||
|
|
8604030404 | ||
|
|
97668217b9 | ||
|
|
c5c865a25f | ||
|
|
06c99154ac | ||
|
|
9d71b58345 | ||
|
|
fb9f77ae5e | ||
|
|
fc2b9bf7f2 | ||
|
|
cdbe00ec6f | ||
|
|
72ff47e204 | ||
|
|
57d3025717 | ||
|
|
6c02558f1b | ||
|
|
0c62863e52 | ||
|
|
d825721dad | ||
|
|
0b3b6f91fc | ||
|
|
3a9b532248 | ||
|
|
67156f4a7a | ||
|
|
4e616cd2ed | ||
|
|
12cf3405e4 | ||
|
|
4383894ad3 | ||
|
|
d0ecaabfd8 | ||
|
|
a286f027c8 | ||
|
|
0ba92c4c03 | ||
|
|
30936573ac | ||
|
|
a451574760 | ||
|
|
5e57deab5f | ||
|
|
eae82edb83 | ||
|
|
1e0cbbf1cc | ||
|
|
3986b4ec9e | ||
|
|
67054e7504 | ||
|
|
2c1ae68dc4 | ||
|
|
69b3edc52c | ||
|
|
732c75d145 | ||
|
|
0f54251459 | ||
|
|
72ef76b8a0 | ||
|
|
81abbdb335 | ||
|
|
67ca79dd3f |
2
.dockerignore
Normal file
2
.dockerignore
Normal file
@@ -0,0 +1,2 @@
|
||||
**/node_modules
|
||||
**/.env
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -2,6 +2,7 @@
|
||||
|
||||
# Logs
|
||||
data-node
|
||||
meili_data
|
||||
logs
|
||||
*.log
|
||||
|
||||
@@ -33,6 +34,7 @@ client/public/main.js.LICENSE.txt
|
||||
# Deployed apps should consider commenting these lines out:
|
||||
# see https://npmjs.org/doc/faq.html#Should-I-check-my-node_modules-folder-into-git
|
||||
node_modules/
|
||||
meili_data/
|
||||
api/node_modules/
|
||||
client/node_modules/
|
||||
bower_components/
|
||||
@@ -47,7 +49,6 @@ bower_components/
|
||||
.env
|
||||
cache.json
|
||||
api/data/
|
||||
.eslintrc.js
|
||||
owner.yml
|
||||
archive
|
||||
.vscode/settings.json
|
||||
|
||||
35
Dockerfile
Normal file
35
Dockerfile
Normal file
@@ -0,0 +1,35 @@
|
||||
FROM node:19-alpine AS react-client
|
||||
WORKDIR /client
|
||||
# copy package.json into the container at /client
|
||||
COPY /client/package*.json /client/
|
||||
# install dependencies
|
||||
RUN npm ci
|
||||
# Copy the current directory contents into the container at /client
|
||||
COPY /client/ /client/
|
||||
# Build webpack artifacts
|
||||
RUN npm run build
|
||||
|
||||
FROM node:19-alpine AS node-api
|
||||
WORKDIR /api
|
||||
# copy package.json into the container at /api
|
||||
COPY /api/package*.json /api/
|
||||
# install dependencies
|
||||
RUN npm ci
|
||||
# Copy the current directory contents into the container at /api
|
||||
COPY /api/ /api/
|
||||
# Copy the client side code
|
||||
COPY --from=react-client /client/public /client/public
|
||||
# Make port 3080 available to the world outside this container
|
||||
EXPOSE 3080
|
||||
# Expose the server to 0.0.0.0
|
||||
ENV HOST=0.0.0.0
|
||||
# Run the app when the container launches
|
||||
CMD ["npm", "start"]
|
||||
|
||||
# Optional: for client with nginx routing
|
||||
FROM nginx:stable-alpine AS nginx-client
|
||||
WORKDIR /usr/share/nginx/html
|
||||
COPY --from=react-client /client/public /usr/share/nginx/html
|
||||
# Add your nginx.conf
|
||||
COPY /client/nginx.conf /etc/nginx/conf.d/default.conf
|
||||
ENTRYPOINT ["nginx", "-g", "daemon off;"]
|
||||
87
LOCAL_INSTALL.md
Normal file
87
LOCAL_INSTALL.md
Normal file
@@ -0,0 +1,87 @@
|
||||
### Local
|
||||
- **Install the prerequisites**
|
||||
- **Download chatgpt-clone**
|
||||
- Download the latest release here: https://github.com/danny-avila/chatgpt-clone/releases/
|
||||
- Or by clicking on the green code button in the top of the page and selecting "Download ZIP"
|
||||
- Or (Recommended if you have Git installed) pull the latest release from the main branch
|
||||
- If you downloaded a zip file, extract the content in "C:/chatgpt-clone/"
|
||||
-**IMPORTANT : If you install the files somewhere else modify the instructions accordingly**
|
||||
|
||||
- **To enable the Conversation search feature:**
|
||||
-IF YOU DON'T WANT THIS FEATURE YOU CAN SKIP THIS STEP
|
||||
- Download MeileSearch latest release from : https://github.com/meilisearch/meilisearch/releases
|
||||
- Copy it to "C:/chatgpt-clone/"
|
||||
- Rename the file to "meilisearch.exe"
|
||||
- Open it by double clicking on it
|
||||
- Copy the generated Master Key and save it somewhere (You will need it later)
|
||||
|
||||
- **Download and Install Node.js**
|
||||
- Navigate to https://nodejs.org/en/download and to download the latest Node.js version for your OS (The Node.js installer includes the NPM package manager.)
|
||||
|
||||
- **Create a MongoDB database**
|
||||
- Navigate to https://www.mongodb.com/ and Sign In or Create an account
|
||||
- Create a new project
|
||||
- Build a Database using the free plan and name the cluster (example: chatgpt-clone)
|
||||
- Use the "Username and Password" method for authentication
|
||||
- Add your current IP to the access list
|
||||
- Then in the Database Deployment tab click on Connect
|
||||
- In "Choose a connection method" select "Connect your application"
|
||||
- Driver = Node.js / Version = 4.1 or later
|
||||
- Copy the connection string and save it somewhere(you will need it later)
|
||||
|
||||
- **Get your OpenAI API key** here: https://platform.openai.com/account/api-keys and save it somewhere safe (you will need it later)
|
||||
|
||||
- **Get your Bing Access Token**
|
||||
- Using MS Edge, navigate to bing.com
|
||||
- Make sure you are logged in
|
||||
- Open the DevTools by pressing F12 on your keyboard
|
||||
- Click on the tab "Application" (On the left of the DevTools)
|
||||
- Expand the "Cookies" (Under "Storage")
|
||||
- You need to copy the value of the "_U" cookie, save it somewhere, you will need it later
|
||||
|
||||
- **Create the ".env" File** You will need all your credentials, (API keys, access tokens, and Mongo Connection String, MeileSearch Master Key)
|
||||
- Open "C:/chatgpt-clone/api/.env.example" in a text editor
|
||||
- At this line **MONGO_URI="mongodb://127.0.0.1:27017/chatgpt-clone"**
|
||||
Replace mongodb://127.0.0.1:27017/chatgpt-clone with the MondoDB connection string you saved earlier, **remove "&w=majority" at the end**
|
||||
- It should look something like this: "MONGO_URI="mongodb+srv://username:password@chatgpt-clone.lfbcwz3.mongodb.net/?retryWrites=true"
|
||||
- At this line **OPENAI_KEY=** you need to add your openai API key
|
||||
- Add your Bing token to this line **BING_TOKEN=** (needed for BingChat & Sydney)
|
||||
- If you want to enable Search, **SEARCH=TRUE** if you do not want to enable search **SEARCH=FALSE**
|
||||
- Add your previously saved MeiliSearch Master key to this line **MEILI_MASTER_KEY=** (the key is needed if search is enabled even on local install or you may encounter errors)
|
||||
- Save the file as **"C:/chatgpt-clone/api/.env"**
|
||||
|
||||
**DO THIS ONCE AFTER EVERY UPDATE**
|
||||
- **Run** `npm ci` in the "C:/chatgpt-clone/api" directory
|
||||
- **Run** `npm ci` in the "C:/chatgpt-clone/client" directory
|
||||
- **Run** `npm run build` in the "C:/chatgpt-clone/client"
|
||||
|
||||
**DO THIS EVERY TIME YOU WANT TO START CHATGPT-CLONE**
|
||||
- **Run** `"meilisearch --master-key put_your_meilesearch_Master_Key_here"` in the "C:/chatgpt-clone" directory (Only if SEARCH=TRUE)
|
||||
- **Run** `npm start` in the "C:/chatgpt-clone/api" directory
|
||||
|
||||
- **Visit** http://localhost:3080 (default port) & enjoy
|
||||
|
||||
|
||||
OPTIONAL BUT RECOMMENDED
|
||||
- **Make a batch file to automate the starting process**
|
||||
- Open a text editor
|
||||
- Paste the following code in a new document
|
||||
- Put your MeiliSearch master key instead of "your_master_key_goes_here"
|
||||
- Save the file as "C:/chatgpt-clone/chatgpt-clone.bat"
|
||||
- you can make a shortcut of this batch file and put it anywhere
|
||||
|
||||
```
|
||||
REM the meilisearch executable needs to be at the root of the chatgpt-clone directory
|
||||
|
||||
start "MeiliSearch" cmd /k "meilisearch --master-key your_master_key_goes_here
|
||||
|
||||
REM ↑↑↑ meilisearch is the name of the meilisearch executable, put your own master key there
|
||||
|
||||
start "ChatGPT-Clone" cmd /k "cd api && npm start"
|
||||
|
||||
REM this batch file goes at the root of the chatgpt-clone directory (C:/chatgpt-clone/)
|
||||
```
|
||||
|
||||
If you update the chatgpt-clone project files, mannually redo the `npm ci` and `npm run build` steps
|
||||
|
||||
To share within network or serve as a public server, set `HOST` to `0.0.0.0` in `.env` file.
|
||||
254
README.md
254
README.md
@@ -1,5 +1,6 @@
|
||||
# ChatGPT Clone #
|
||||

|
||||
https://user-images.githubusercontent.com/110412045/223754183-8b7f45ce-6517-4bd5-9b39-c624745bf399.mp4
|
||||
|
||||
## All AI Conversations under One Roof. ##
|
||||
Assistant AIs are the future and OpenAI revolutionized this movement with ChatGPT. While numerous methods exist to integrate them, this app commemorates the original styling of ChatGPT, with the ability to integrate any current/future AI models, while improving upon original client features, such as conversation search and prompt templates (currently WIP).
|
||||
|
||||
@@ -7,16 +8,78 @@
|
||||
|
||||
## Updates
|
||||
<details open>
|
||||
<summary><strong>2023-03-23</strong></summary>
|
||||
|
||||
|
||||
|
||||
**Released [v0.1.0](https://github.com/danny-avila/chatgpt-clone/releases/tag/v0.1.0)**, **searching messages/conversations is live!** Up next is more custom parameters for customGpt's. Join the discord server for more immediate assistance and update: **[community discord server](https://discord.gg/NGaa9RPCft)**
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Previous Updates</strong></summary>
|
||||
|
||||
<details>
|
||||
<summary><strong>2023-03-22</strong></summary>
|
||||
|
||||
|
||||
|
||||
**Released [v0.0.6](https://github.com/danny-avila/chatgpt-clone/releases/tag/v0.0.6)**, the latest stable release before **Searching messages** goes live tomorrow. See exact updates to date in the tag link. By request, there is now also a **[community discord server](https://discord.gg/NGaa9RPCft)**
|
||||
|
||||
</details>
|
||||
<details>
|
||||
<summary><strong>2023-03-20</strong></summary>
|
||||
|
||||
|
||||
|
||||
**Searching messages** is almost here as I test more of its functionality. There've been a lot of great features requested and great contributions and I will work on some soon, namely, further customizing the custom gpt params with sliders similar to the OpenAI playground, and including the custom params and system messages available to Bing.
|
||||
|
||||
The above features are next and then I will have to focus on building the **test environment.** I would **greatly appreciate** help in this area with any test environment you're familiar with (mocha, chai, jest, playwright, puppeteer). This is to aid in the velocity of contributing and to save time I spend debugging.
|
||||
|
||||
On that note, I had to switch the default branch due to some breaking changes that haven't been straight forward to debug, mainly related to node-chat-gpt the main dependency of the project. Thankfully, my working branch, now switched to default as main, is working as expected.
|
||||
|
||||
</details>
|
||||
<details>
|
||||
<summary><strong>2023-03-16</strong></summary>
|
||||
|
||||
|
||||
|
||||
[Latest release (v0.0.4)](https://github.com/danny-avila/chatgpt-clone/releases/tag/v0.0.4) includes Resubmitting messages & Branching messages, which mirrors official ChatGPT feature of editing a sent message, that then branches the conversation into separate message paths (works only with ChatGPT)
|
||||
|
||||
Full details and [example here](https://github.com/danny-avila/chatgpt-clone/releases/tag/v0.0.4). Message search is on the docket
|
||||
|
||||
</details>
|
||||
<details>
|
||||
<summary><strong>2023-03-12</strong></summary>
|
||||
|
||||
|
||||
|
||||
|
||||
Really thankful for all the issues reported and contributions made, the project's features and improvements have accelerated as result. Honorable mention is [wtlyu](https://github.com/wtlyu) for contributing a lot of mindful code, namely hostname configuration and mobile styling. I will upload images on next release for faster docker setup, and starting updating them simultaneously with this repo.
|
||||
|
||||
|
||||
|
||||
Many improvements across the board, the biggest is being able to start conversations simultaneously (again thanks to [wtlyu](https://github.com/wtlyu) for bringing it to my attention), as you can switch conversations or start a new chat without any response streaming from a prior one, as the backend will still process/save client responses. Just watch out for any rate limiting from OpenAI/Microsoft if this is done excessively.
|
||||
|
||||
|
||||
Adding support for conversation search is next! Thank you [mysticaltech](https://github.com/mysticaltech) for bringing up a method I can use for this.
|
||||
</details>
|
||||
<details>
|
||||
<summary><strong>2023-03-09</strong></summary>
|
||||
Released v.0.0.2
|
||||
|
||||
Adds Sydney (jailbroken Bing AI) to the model menu. Thank you [DavesDevFails](https://github.com/DavesDevFails) for bringing it to my attention in this [issue](https://github.com/danny-avila/chatgpt-clone/issues/13). Bing/Sydney now correctly cite links, more styling to come. Fix some overlooked bugs, and model menu doesn't close upon deleting a customGpt.
|
||||
|
||||
|
||||
I've re-enabled the ChatGPT browser client (free version) since it might be working for most people, it no longer works for me. Sydney is the best free route anyway.
|
||||
</details>
|
||||
<details>
|
||||
<summary><strong>2023-03-07</strong></summary>
|
||||
Due to increased interest in the repo, I've dockerized the app as of this update for quick setup! See setup instructions below. I realize this still takes some time with installing docker dependencies, so it's on the roadmap to have a deployed demo. Besides this, I've made major improvements for a lot of the existing features across the board, mainly UI/UX.
|
||||
|
||||
|
||||
Also worth noting, the method to access the Free Version is no longer working, so I've removed it from model selection until further notice.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Previous Updates</strong></summary>
|
||||
|
||||
<details>
|
||||
<summary><strong>2023-03-04</strong></summary>
|
||||
Custom prompt prefixing and labeling is now supported through the official API. This nets some interesting results when you need ChatGPT for specific uses or entertainment. Select 'CustomGPT' in the model menu to configure this, and you can choose to save the configuration or reference it by conversation. Model selection will change by conversation.
|
||||
@@ -45,21 +108,28 @@ Currently, this project is only functional with the `text-davinci-003` model.
|
||||
</details>
|
||||
|
||||
# Table of Contents
|
||||
* [Roadmap](#roadmap)
|
||||
* [Features](#features)
|
||||
* [Tech Stack](#tech-stack)
|
||||
* [Getting Started](#getting-started)
|
||||
* [Prerequisites](#prerequisites)
|
||||
* [Usage](#usage)
|
||||
* [Local (npm)](#npm)
|
||||
* [Docker](#docker)
|
||||
* [Access Tokens](#access-tokens)
|
||||
* [Updating](#updating)
|
||||
* [Use Cases](#use-cases)
|
||||
* [Origin](#origin)
|
||||
* [Caveats](#caveats)
|
||||
* [Contributing](#contributing)
|
||||
* [License](#license)
|
||||
- [ChatGPT Clone](#chatgpt-clone)
|
||||
- [All AI Conversations under One Roof.](#all-ai-conversations-under-one-roof)
|
||||
- [Updates](#updates)
|
||||
- [Table of Contents](#table-of-contents)
|
||||
- [Roadmap](#roadmap)
|
||||
- [Features](#features)
|
||||
- [Tech Stack](#tech-stack)
|
||||
- [Getting Started](#getting-started)
|
||||
- [Prerequisites](#prerequisites)
|
||||
- [Usage](#usage)
|
||||
- [Local](#local)
|
||||
- [Docker](#docker)
|
||||
- [Access Tokens](#access-tokens)
|
||||
- [Proxy](#proxy)
|
||||
- [User System](#user-system)
|
||||
- [Updating](#updating)
|
||||
- [Use Cases](#use-cases)
|
||||
- [Origin](#origin)
|
||||
- [Caveats](#caveats)
|
||||
- [Regarding use of Official ChatGPT API](#regarding-use-of-official-chatgpt-api)
|
||||
- [Contributing](#contributing)
|
||||
- [License](#license)
|
||||
|
||||
## Roadmap
|
||||
|
||||
@@ -67,7 +137,10 @@ Currently, this project is only functional with the `text-davinci-003` model.
|
||||
|
||||
> This is a work in progress. I'm building this in public. FYI there is still a lot of tech debt to cleanup. You can follow the progress here or on my [Linkedin](https://www.linkedin.com/in/danny-avila).
|
||||
|
||||
Here are my recently completed and planned features:
|
||||
|
||||
|
||||
<details>
|
||||
<summary><strong>Here are my recently completed and planned features:</strong></summary>
|
||||
|
||||
- [x] Persistent conversation
|
||||
- [x] Rename, delete conversations
|
||||
@@ -79,34 +152,47 @@ Here are my recently completed and planned features:
|
||||
- [x] Customize prompt prefix/label (custom ChatGPT using official API)
|
||||
- [x] Server convo pagination (limit fetch and load more with 'show more' button)
|
||||
- [x] Config file for easy startup (docker compose)
|
||||
- [x] Mobile styling (thanks to [wtlyu](https://github.com/wtlyu))
|
||||
- [x] Resubmit/edit sent messages (thanks to [wtlyu](https://github.com/wtlyu))
|
||||
- [ ] Message Search
|
||||
- [ ] Custom params for ChatGPT API (temp, top_p, presence_penalty)
|
||||
- [ ] Bing AI Styling (params, suggested responses, convo end, etc.) - **In progress**
|
||||
- [ ] Add warning before clearing convos
|
||||
- [ ] Build test suite for CI/CD
|
||||
- [ ] Conversation Search (by title)
|
||||
- [ ] Resubmit/edit sent messages
|
||||
- [ ] Semantic Search Option (requires more tokens)
|
||||
- [ ] Bing AI Styling (for suggested responses, convo end, etc.)
|
||||
- [ ] Prompt Templates/Search
|
||||
- [ ] Refactor/clean up code (tech debt)
|
||||
- [ ] Optional use of local storage for credentials
|
||||
- [ ] Mobile styling (half-finished)
|
||||
- [ ] Deploy demo
|
||||
|
||||
</details>
|
||||
|
||||
### Features
|
||||
|
||||
- Response streaming identical to ChatGPT through server-sent events
|
||||
- UI from original ChatGPT, including Dark mode
|
||||
- AI model selection (official ChatGPT API, BingAI, ChatGPT Free)
|
||||
- Create and Save custom ChatGPTs*
|
||||
- **3/23/23** - Search all messages/conversations - [see details here](https://github.com/danny-avila/chatgpt-clone/releases/tag/v0.1.0)
|
||||
|
||||
^* ChatGPT can be 'customized' by setting a system message or prompt prefix and alternate 'role' to the API request
|
||||
^* ChatGPT can be 'customized' by setting a system message or prompt prefix and alternate 'role' to the API request^
|
||||
|
||||
[More info here](https://platform.openai.com/docs/guides/chat/instructing-chat-models). Here's an [example from this app.]()
|
||||
|
||||
### Tech Stack
|
||||
|
||||
- Utilizes [node-chatgpt-api](https://github.com/waylaidwanderer/node-chatgpt-api)
|
||||
|
||||
<details>
|
||||
<summary><strong>This project uses:</strong></summary>
|
||||
|
||||
|
||||
|
||||
- [node-chatgpt-api](https://github.com/waylaidwanderer/node-chatgpt-api)
|
||||
- No React boilerplate/toolchain/clone tutorials, created from scratch with react@latest
|
||||
- Use of Tailwind CSS and [shadcn/ui](https://github.com/shadcn/ui) components
|
||||
- Docker, useSWR, Redux, Express, MongoDB, [Keyv](https://www.npmjs.com/package/keyv)
|
||||
</details>
|
||||
|
||||
|
||||
|
||||
## Getting Started
|
||||
|
||||
@@ -114,6 +200,7 @@ Here are my recently completed and planned features:
|
||||
- npm
|
||||
- Node.js >= 19.0.0
|
||||
- MongoDB installed or [MongoDB Atlas](https://account.mongodb.com/account/login) (required if not using Docker)
|
||||
- MongoDB does not support older ARM CPUs like those found in Raspberry Pis. However, you can make it work by setting MongoDB's version to mongo:4.4.18 in docker-compose.yml, the most recent version compatible with
|
||||
- [Docker (optional)](https://www.docker.com/get-started/)
|
||||
- [OpenAI API key](https://platform.openai.com/account/api-keys)
|
||||
- BingAI, ChatGPT access tokens (optional, free AIs)
|
||||
@@ -127,34 +214,20 @@ Here are my recently completed and planned features:
|
||||
- If using MongoDB Atlas, remove `&w=majority` from default connection string.
|
||||
|
||||
### Local
|
||||
- **Run npm** install in both the api and client directories
|
||||
- **Provide** all credentials, (API keys, access tokens, and Mongo Connection String) in api/.env [(see .env example)](api/.env.example)
|
||||
- **Run** `npm run build` in /client/ dir, `npm start` in /api/ dir
|
||||
- **Visit** http://localhost:3080 (default port) & enjoy
|
||||
### **[In-depth instructions here!](https://github.com/danny-avila/chatgpt-clone/blob/0d4f0f74c04337aaf51b9a3eef898165a7009156/LOCAL_INSTALL.md)**
|
||||
- thank you [@fuegovic](https://github.com/fuegovic)!
|
||||
|
||||
### Docker
|
||||
|
||||
- **Provide** all credentials, (API keys, access tokens, and Mongo Connection String) in [docker-compose.yml](docker-compose.yml) under api service
|
||||
- **Build images** in both /api/ and /client/ directories (will eventually share through docker hub)
|
||||
- `api/`
|
||||
```bash
|
||||
docker build -t node-api .
|
||||
```
|
||||
- `client/`
|
||||
```bash
|
||||
docker build -t react-client .
|
||||
```
|
||||
- **Run** `docker-compose build` in project root dir and then `docker-compose up` to start the app
|
||||
- **Run** `docker-compose up` to start the app
|
||||
- Note: MongoDB does not support older ARM CPUs like those found in Raspberry Pis. However, you can make it work by setting MongoDB's version to mongo:4.4.18 in docker-compose.yml, the most recent version compatible with
|
||||
|
||||
### Access Tokens
|
||||
|
||||
<details>
|
||||
<summary><strong>ChatGPT Free Instructions</strong></summary>
|
||||
|
||||
|
||||
**This has been disabled as is no longer working as of 3-07-23**
|
||||
|
||||
|
||||
To get your Access token For ChatGPT 'Free Version', login to chat.openai.com, then visit https://chat.openai.com/api/auth/session.
|
||||
|
||||
|
||||
@@ -169,11 +242,89 @@ The Bing Access Token is the "_U" cookie from bing.com. Use dev tools or an exte
|
||||
**Note:** Specific error handling and styling for this model is still in progress.
|
||||
</details>
|
||||
|
||||
### Proxy
|
||||
|
||||
If your server cannot connect to the chatGPT API server by some reason, (eg in China). You can set a environment variable `PROXY`. This will be transmitted to `node-chatgpt-api` interface.
|
||||
|
||||
**Warning:** `PROXY` is not `reverseProxyUrl` in `node-chatgpt-api`
|
||||
|
||||
<details>
|
||||
<summary><strong>Set up proxy in local environment </strong></summary>
|
||||
|
||||
Here is two ways to set proxy.
|
||||
- Option 1: system level environment
|
||||
`export PROXY="http://127.0.0.1:7890"`
|
||||
- Option 2: set in .env file
|
||||
`PROXY="http://127.0.0.1:7890"`
|
||||
|
||||
**Change `http://127.0.0.1:7890` to your proxy server**
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Set up proxy in docker environment </strong></summary>
|
||||
|
||||
set in docker-compose.yml file, under services - api - environment
|
||||
|
||||
```
|
||||
api:
|
||||
...
|
||||
environment:
|
||||
...
|
||||
- "PROXY=http://127.0.0.1:7890"
|
||||
# add this line ↑
|
||||
```
|
||||
|
||||
**Change `http://127.0.0.1:7890` to your proxy server**
|
||||
|
||||
</details>
|
||||
|
||||
### User System
|
||||
|
||||
By default, there is no user system enabled, so anyone can access your server.
|
||||
|
||||
**This project is not designed to provide a complete and full-featured user system.** It's not high priority task and might never be provided.
|
||||
|
||||
[wtlyu](https://github.com/wtlyu) provide a sample user system structure, that you can implement your own user system. It's simple and not a ready-for-use edition.
|
||||
|
||||
(If you want to implement your user system, open this ↓)
|
||||
|
||||
<details>
|
||||
<summary><strong>Implement your own user system </strong></summary>
|
||||
|
||||
To enable the user system, set `ENABLE_USER_SYSTEM=1` in your `.env` file.
|
||||
|
||||
The sample structure is simple. It provide three basic endpoint:
|
||||
|
||||
1. `/auth/login` will redirect to your own login url. In the sample code, it's `/auth/your_login_page`.
|
||||
2. `/auth/logout` will redirect to your own logout url. In the sample code, it's `/auth/your_login_page/logout`.
|
||||
3. `/api/me` will return the userinfo: `{ username, display }`.
|
||||
1. `username` will be used in db, used to distinguish between users.
|
||||
2. `display` will be displayed in UI.
|
||||
|
||||
The only one thing that drive user system work is `req.session.user`. Once it's set, the client will be trusted. Set to `null` if logout.
|
||||
|
||||
Please refer to `/api/server/routes/authYourLogin.js` file. It's very clear and simple to tell you how to implement your user system.
|
||||
|
||||
Or you can ask chatGPT to write the code for you, here is one example to connect LDAP:
|
||||
|
||||
```
|
||||
Please write me an express module, that serve the login and logout endpoint as a router. The login and logout uri is '/' and '/logout'. Once loginned, save display name and username in session.user, as {display, username}. Then redirect to '/'. Please write the code using express and other lib, and storage any server configuration in a config variable. I want the user to be connected to my LDAP server.
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
### Updating
|
||||
- As the project is still a work-in-progress, you should pull the latest and run some of the steps above again
|
||||
|
||||
- As the project is still a work-in-progress, you should pull the latest and run the steps over. Reset your browser cache/clear site data.
|
||||
|
||||
## Use Cases ##
|
||||
|
||||
|
||||
|
||||
<details>
|
||||
<summary><strong> Why use this project? </strong></summary>
|
||||
|
||||
- One stop shop for all conversational AIs, with the added bonus of searching past conversations.
|
||||
- Using the official API, you'd have to generate 7.5 million words to expense the same cost as ChatGPT Plus ($20).
|
||||
- ChatGPT/Google Bard/Bing AI conversations are lost in space or
|
||||
@@ -189,6 +340,8 @@ The Bing Access Token is the "_U" cookie from bing.com. Use dev tools or an exte
|
||||
- **ChatGPT Free is down.**
|
||||
|
||||

|
||||
|
||||
</details>
|
||||
|
||||
|
||||
## Origin ##
|
||||
@@ -207,7 +360,12 @@ This means my implementation or the underlying model may not behave exactly the
|
||||
- This works in a similar way to ChatGPT, except I'm pretty sure they have some additional way of retrieving context from earlier messages when needed (which can probably be achieved with embeddings, but I consider that out-of-scope for now).
|
||||
|
||||
## Contributing
|
||||
If you'd like to contribute, please create a pull request with a detailed description of your changes.
|
||||
|
||||
Contributions and suggestions welcome! Bug reports and fixes are welcome!
|
||||
|
||||
For new features, components, or extensions, please open an issue and discuss before sending a PR.
|
||||
|
||||
- Join the [Discord community](https://discord.gg/NGaa9RPCft)
|
||||
|
||||
## License
|
||||
This project is licensed under the MIT License.
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
/node_modules
|
||||
.env
|
||||
@@ -1,7 +1,64 @@
|
||||
OPENAI_KEY=
|
||||
# Server configuration.
|
||||
# The server will listen to localhost:3080 request by default. You can set the target ip as you want.
|
||||
# If you want this server can be used outside your local machine, for example to share with other
|
||||
# machine or expose this from a docker container, set HOST=0.0.0.0 or your external ip interface.
|
||||
#
|
||||
# Tips: HOST=0.0.0.0 means listening on all interface. It's not a real ip. Use localhost:port rather
|
||||
# than 0.0.0.0:port to open it.
|
||||
HOST=localhost
|
||||
PORT=3080
|
||||
NODE_ENV=development
|
||||
|
||||
# Change this to proxy any API request. It's useful if your machine have difficulty calling the original API server.
|
||||
# PROXY="http://YOUR_PROXY_SERVER"
|
||||
|
||||
# Change this to your MongoDB URI if different and I recommend appending chatgpt-clone
|
||||
MONGO_URI="mongodb://127.0.0.1:27017/chatgpt-clone"
|
||||
CHATGPT_TOKEN=""
|
||||
BING_TOKEN=""
|
||||
|
||||
# API key configuration.
|
||||
# Leave blank if you don't want them.
|
||||
OPENAI_KEY=
|
||||
BING_TOKEN=
|
||||
|
||||
# ChatGPT Browser Client (free but use at your own risk)
|
||||
# Access token from https://chat.openai.com/api/auth/session
|
||||
# Exposes your access token to a 3rd party
|
||||
CHATGPT_TOKEN=
|
||||
# If you have access to other models on the official site, you can use them here.
|
||||
# Defaults to 'text-davinci-002-render-sha' if left empty.
|
||||
# options: gpt-4, text-davinci-002-render, text-davinci-002-render-paid, or text-davinci-002-render-sha
|
||||
# You cannot use a model that your account does not have access to. You can check
|
||||
# which ones you have access to by opening DevTools and going to the Network tab.
|
||||
# Refresh the page and look at the response body for https://chat.openai.com/backend-api/models.
|
||||
BROWSER_MODEL=
|
||||
|
||||
# ENABLING SEARCH MESSAGES/CONVOS
|
||||
# Requires installation of free self-hosted Meilisearch or Paid Remote Plan (Remote not tested)
|
||||
# The easiest setup for this is through docker-compose, which takes care of it for you.
|
||||
# SEARCH=TRUE
|
||||
SEARCH=TRUE
|
||||
|
||||
# REQUIRED FOR SEARCH: MeiliSearch Host, mainly for api server to connect to the search server.
|
||||
# must replace '0.0.0.0' with 'meilisearch' if serving meilisearch with docker-compose
|
||||
# MEILI_HOST='http://meilisearch:7700' # <-- docker-compose (should already be setup on docker-compose.yml)
|
||||
MEILI_HOST='http://0.0.0.0:7700' # <-- local/remote
|
||||
|
||||
# REQUIRED FOR SEARCH: MeiliSearch HTTP Address, mainly for docker-compose to expose the search server.
|
||||
# must replace '0.0.0.0' with 'meilisearch' if serving meilisearch with docker-compose
|
||||
# MEILI_HTTP_ADDR='meilisearch:7700' # <-- docker-compose (should already be setup on docker-compose.yml)
|
||||
MEILI_HTTP_ADDR='0.0.0.0:7700' # <-- local/remote
|
||||
|
||||
# REQUIRED FOR SEARCH: In production env., needs a secure key, feel free to generate your own.
|
||||
# This master key must be at least 16 bytes, composed of valid UTF-8 characters.
|
||||
# Meilisearch will throw an error and refuse to launch if no master key is provided or if it is under 16 bytes,
|
||||
# Meilisearch will suggest a secure autogenerated master key.
|
||||
# Using docker, it seems recognized as production so use a secure key.
|
||||
# MEILI_MASTER_KEY= # <-- empty/insecure key works for local/remote
|
||||
MEILI_MASTER_KEY=JKMW-hGc7v_D1FkJVdbRSDNFLZcUv3S75yrxXP0SmcU # <-- ready made secure key for docker-compose
|
||||
|
||||
|
||||
# User System
|
||||
# global enable/disable the sample user system.
|
||||
# this is not a ready to use user system.
|
||||
# dont't use it, unless you can write your own code.
|
||||
# ENABLE_USER_SYSTEM= # <-- make sure you don't comment this back in if you're not using your own user system
|
||||
|
||||
39
api/.eslintrc.js
Normal file
39
api/.eslintrc.js
Normal file
@@ -0,0 +1,39 @@
|
||||
module.exports = {
|
||||
env: {
|
||||
es2021: true,
|
||||
node: true
|
||||
},
|
||||
extends: ['eslint:recommended'],
|
||||
overrides: [],
|
||||
parserOptions: {
|
||||
ecmaVersion: 'latest',
|
||||
sourceType: 'module'
|
||||
},
|
||||
rules: {
|
||||
indent: ['error', 2, { SwitchCase: 1 }],
|
||||
'max-len': [
|
||||
'error',
|
||||
{
|
||||
code: 150,
|
||||
ignoreStrings: true,
|
||||
ignoreTemplateLiterals: true,
|
||||
ignoreComments: true
|
||||
}
|
||||
],
|
||||
'linebreak-style': 0,
|
||||
'arrow-parens': [2, 'as-needed', { requireForBlockBody: true }],
|
||||
// 'no-plusplus': ['error', { allowForLoopAfterthoughts: true }],
|
||||
'no-console': 'off',
|
||||
'import/extensions': 'off',
|
||||
'no-use-before-define': [
|
||||
'error',
|
||||
{
|
||||
functions: false
|
||||
}
|
||||
],
|
||||
'no-promise-executor-return': 'off',
|
||||
'no-param-reassign': 'off',
|
||||
'no-continue': 'off',
|
||||
'no-restricted-syntax': 'off'
|
||||
}
|
||||
};
|
||||
22
api/.prettierrc
Normal file
22
api/.prettierrc
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"arrowParens": "avoid",
|
||||
"bracketSpacing": true,
|
||||
"endOfLine": "lf",
|
||||
"htmlWhitespaceSensitivity": "css",
|
||||
"insertPragma": false,
|
||||
"singleAttributePerLine": true,
|
||||
"bracketSameLine": false,
|
||||
"jsxBracketSameLine": false,
|
||||
"jsxSingleQuote": false,
|
||||
"printWidth": 110,
|
||||
"proseWrap": "preserve",
|
||||
"quoteProps": "as-needed",
|
||||
"requirePragma": false,
|
||||
"semi": true,
|
||||
"singleQuote": true,
|
||||
"tabWidth": 2,
|
||||
"trailingComma": "none",
|
||||
"useTabs": false,
|
||||
"vueIndentScriptAndStyle": false,
|
||||
"parser": "babel"
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
FROM node:19-alpine
|
||||
WORKDIR /api
|
||||
# copy package.json into the container at /api
|
||||
COPY package*.json /api/
|
||||
# install dependencies
|
||||
RUN npm install
|
||||
# Copy the current directory contents into the container at /api
|
||||
COPY . /api/
|
||||
# Make port 3080 available to the world outside this container
|
||||
EXPOSE 3080
|
||||
# Run the app when the container launches
|
||||
CMD ["npm", "start"]
|
||||
|
||||
# docker build -t node-api .
|
||||
@@ -1,32 +0,0 @@
|
||||
require('dotenv').config();
|
||||
const { KeyvFile } = require('keyv-file');
|
||||
|
||||
const clientOptions = {
|
||||
// Warning: This will expose your access token to a third party. Consider the risks before using this.
|
||||
reverseProxyUrl: 'https://chatgpt.duti.tech/api/conversation',
|
||||
// Access token from https://chat.openai.com/api/auth/session
|
||||
accessToken: process.env.CHATGPT_TOKEN
|
||||
};
|
||||
|
||||
const browserClient = async ({ text, progressCallback, convo }) => {
|
||||
const { ChatGPTBrowserClient } = await import('@waylaidwanderer/chatgpt-api');
|
||||
|
||||
const store = {
|
||||
store: new KeyvFile({ filename: './data/cache.json' })
|
||||
};
|
||||
|
||||
const client = new ChatGPTBrowserClient(clientOptions, store);
|
||||
|
||||
let options = {
|
||||
onProgress: async (partialRes) => await progressCallback(partialRes)
|
||||
};
|
||||
|
||||
if (!!convo.parentMessageId && !!convo.conversationId) {
|
||||
options = { ...options, ...convo };
|
||||
}
|
||||
|
||||
const res = await client.sendMessage(text, options);
|
||||
return res;
|
||||
};
|
||||
|
||||
module.exports = { browserClient };
|
||||
@@ -1,38 +0,0 @@
|
||||
require('dotenv').config();
|
||||
const Keyv = require('keyv');
|
||||
const { Configuration, OpenAIApi } = require('openai');
|
||||
const messageStore = new Keyv(process.env.MONGODB_URI, { namespace: 'chatgpt' });
|
||||
|
||||
const ask = async (question, progressCallback, convo) => {
|
||||
const { ChatGPTAPI } = await import('chatgpt');
|
||||
const api = new ChatGPTAPI({ apiKey: process.env.OPENAI_KEY, messageStore });
|
||||
let options = {
|
||||
onProgress: async (partialRes) => {
|
||||
if (partialRes.text.length > 0) {
|
||||
await progressCallback(partialRes);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (!!convo.parentMessageId && !!convo.conversationId) {
|
||||
options = { ...options, ...convo };
|
||||
}
|
||||
|
||||
const res = await api.sendMessage(question, options);
|
||||
return res;
|
||||
};
|
||||
|
||||
const titleConvo = async (message, response, model) => {
|
||||
const configuration = new Configuration({
|
||||
apiKey: process.env.OPENAI_KEY
|
||||
});
|
||||
const openai = new OpenAIApi(configuration);
|
||||
const completion = await openai.createCompletion({
|
||||
model: 'text-davinci-002',
|
||||
prompt: `Write a short title in title case, ideally in 5 words or less, and do not refer to the user or ${model}, that summarizes this conversation:\nUser:"${message}"\n${model}:"${response}"\nTitle: `
|
||||
});
|
||||
|
||||
return completion.data.choices[0].text.replace(/\n/g, '');
|
||||
};
|
||||
|
||||
module.exports = { ask, titleConvo };
|
||||
@@ -1,8 +1,8 @@
|
||||
require('dotenv').config();
|
||||
const { KeyvFile } = require('keyv-file');
|
||||
|
||||
const askBing = async ({ text, progressCallback, convo }) => {
|
||||
const { BingAIClient } = (await import('@waylaidwanderer/chatgpt-api'));
|
||||
const askBing = async ({ text, onProgress, convo }) => {
|
||||
const { BingAIClient } = await import('@waylaidwanderer/chatgpt-api');
|
||||
|
||||
const bingAIClient = new BingAIClient({
|
||||
// "_U" cookie from bing.com
|
||||
@@ -10,19 +10,26 @@ const askBing = async ({ text, progressCallback, convo }) => {
|
||||
// If the above doesn't work, provide all your cookies as a string instead
|
||||
// cookies: '',
|
||||
debug: false,
|
||||
store: new KeyvFile({ filename: './data/cache.json' })
|
||||
cache: { store: new KeyvFile({ filename: './data/cache.json' }) },
|
||||
proxy: process.env.PROXY || null
|
||||
});
|
||||
|
||||
let options = {
|
||||
onProgress: async (partialRes) => await progressCallback(partialRes),
|
||||
};
|
||||
|
||||
let options = { onProgress };
|
||||
if (convo) {
|
||||
options = { ...options, ...convo };
|
||||
}
|
||||
|
||||
const res = await bingAIClient.sendMessage(text, options
|
||||
);
|
||||
if (options?.jailbreakConversationId == 'false') {
|
||||
options.jailbreakConversationId = false;
|
||||
}
|
||||
|
||||
if (convo.toneStyle) {
|
||||
options.toneStyle = convo.toneStyle;
|
||||
}
|
||||
|
||||
console.log('bing options', options);
|
||||
|
||||
const res = await bingAIClient.sendMessage(text, options);
|
||||
|
||||
return res;
|
||||
|
||||
43
api/app/clients/chatgpt-browser.js
Normal file
43
api/app/clients/chatgpt-browser.js
Normal file
@@ -0,0 +1,43 @@
|
||||
require('dotenv').config();
|
||||
const { KeyvFile } = require('keyv-file');
|
||||
const set = new Set(["gpt-4", "text-davinci-002-render", "text-davinci-002-render-paid", "text-davinci-002-render-sha"]);
|
||||
|
||||
const clientOptions = {
|
||||
// Warning: This will expose your access token to a third party. Consider the risks before using this.
|
||||
reverseProxyUrl: 'https://bypass.duti.tech/api/conversation',
|
||||
// Access token from https://chat.openai.com/api/auth/session
|
||||
accessToken: process.env.CHATGPT_TOKEN,
|
||||
// debug: true
|
||||
proxy: process.env.PROXY || null,
|
||||
};
|
||||
|
||||
// You can check which models you have access to by opening DevTools and going to the Network tab.
|
||||
// Refresh the page and look at the response body for https://chat.openai.com/backend-api/models.
|
||||
if (set.has(process.env.BROWSER_MODEL)) {
|
||||
clientOptions.model = process.env.BROWSER_MODEL;
|
||||
}
|
||||
|
||||
const browserClient = async ({ text, onProgress, convo, abortController }) => {
|
||||
const { ChatGPTBrowserClient } = await import('@waylaidwanderer/chatgpt-api');
|
||||
|
||||
const store = {
|
||||
store: new KeyvFile({ filename: './data/cache.json' })
|
||||
};
|
||||
|
||||
const client = new ChatGPTBrowserClient(clientOptions, store);
|
||||
let options = { onProgress, abortController };
|
||||
|
||||
if (!!convo.parentMessageId && !!convo.conversationId) {
|
||||
options = { ...options, ...convo };
|
||||
}
|
||||
|
||||
/* will error if given a convoId at the start */
|
||||
if (convo.parentMessageId.startsWith('0000')) {
|
||||
delete options.conversationId;
|
||||
}
|
||||
|
||||
const res = await client.sendMessage(text, options);
|
||||
return res;
|
||||
};
|
||||
|
||||
module.exports = { browserClient };
|
||||
@@ -5,20 +5,18 @@ const clientOptions = {
|
||||
modelOptions: {
|
||||
model: 'gpt-3.5-turbo'
|
||||
},
|
||||
proxy: process.env.PROXY || null,
|
||||
debug: false
|
||||
};
|
||||
|
||||
const askClient = async ({ text, progressCallback, convo }) => {
|
||||
const askClient = async ({ text, onProgress, convo, abortController }) => {
|
||||
const ChatGPTClient = (await import('@waylaidwanderer/chatgpt-api')).default;
|
||||
const store = {
|
||||
store: new KeyvFile({ filename: './data/cache.json' })
|
||||
};
|
||||
|
||||
const client = new ChatGPTClient(process.env.OPENAI_KEY, clientOptions, store);
|
||||
|
||||
let options = {
|
||||
onProgress: async (partialRes) => await progressCallback(partialRes)
|
||||
};
|
||||
let options = { onProgress, abortController };
|
||||
|
||||
if (!!convo.parentMessageId && !!convo.conversationId) {
|
||||
options = { ...options, ...convo };
|
||||
@@ -5,10 +5,11 @@ const clientOptions = {
|
||||
modelOptions: {
|
||||
model: 'gpt-3.5-turbo'
|
||||
},
|
||||
proxy: process.env.PROXY || null,
|
||||
debug: false
|
||||
};
|
||||
|
||||
const customClient = async ({ text, progressCallback, convo, promptPrefix, chatGptLabel }) => {
|
||||
const customClient = async ({ text, onProgress, convo, promptPrefix, chatGptLabel, abortController }) => {
|
||||
const ChatGPTClient = (await import('@waylaidwanderer/chatgpt-api')).default;
|
||||
const store = {
|
||||
store: new KeyvFile({ filename: './data/cache.json' })
|
||||
@@ -16,16 +17,13 @@ const customClient = async ({ text, progressCallback, convo, promptPrefix, chatG
|
||||
|
||||
clientOptions.chatGptLabel = chatGptLabel;
|
||||
|
||||
if (promptPrefix.length > 0) {
|
||||
if (promptPrefix?.length > 0) {
|
||||
clientOptions.promptPrefix = promptPrefix;
|
||||
}
|
||||
|
||||
const client = new ChatGPTClient(process.env.OPENAI_KEY, clientOptions, store);
|
||||
|
||||
let options = {
|
||||
onProgress: async (partialRes) => await progressCallback(partialRes)
|
||||
};
|
||||
|
||||
let options = { onProgress, abortController };
|
||||
if (!!convo.parentMessageId && !!convo.conversationId) {
|
||||
options = { ...options, ...convo };
|
||||
}
|
||||
40
api/app/clients/sydney.js
Normal file
40
api/app/clients/sydney.js
Normal file
@@ -0,0 +1,40 @@
|
||||
require('dotenv').config();
|
||||
const { KeyvFile } = require('keyv-file');
|
||||
|
||||
const askSydney = async ({ text, onProgress, convo }) => {
|
||||
const { BingAIClient } = (await import('@waylaidwanderer/chatgpt-api'));
|
||||
|
||||
const sydneyClient = new BingAIClient({
|
||||
// "_U" cookie from bing.com
|
||||
userToken: process.env.BING_TOKEN,
|
||||
// If the above doesn't work, provide all your cookies as a string instead
|
||||
// cookies: '',
|
||||
debug: false,
|
||||
cache: { store: new KeyvFile({ filename: './data/cache.json' }) }
|
||||
});
|
||||
|
||||
let options = {
|
||||
jailbreakConversationId: true,
|
||||
onProgress,
|
||||
};
|
||||
|
||||
if (convo.jailbreakConversationId) {
|
||||
options = { ...options, jailbreakConversationId: convo.jailbreakConversationId, parentMessageId: convo.parentMessageId };
|
||||
}
|
||||
|
||||
if (convo.toneStyle) {
|
||||
options.toneStyle = convo.toneStyle;
|
||||
}
|
||||
|
||||
console.log('sydney options', options);
|
||||
|
||||
const res = await sydneyClient.sendMessage(text, options
|
||||
);
|
||||
|
||||
return res;
|
||||
|
||||
// for reference:
|
||||
// https://github.com/waylaidwanderer/node-chatgpt-api/blob/main/demos/use-bing-client.js
|
||||
};
|
||||
|
||||
module.exports = { askSydney };
|
||||
@@ -1,54 +0,0 @@
|
||||
const { ModelOperations } = require('@vscode/vscode-languagedetection');
|
||||
const codeRegex = /(```[\s\S]*?```)/g;
|
||||
const languageMatch = /```(\w+)/;
|
||||
|
||||
const detectCode = async (text) => {
|
||||
try {
|
||||
if (!text.match(codeRegex)) {
|
||||
// console.log('disqualified for non-code match')
|
||||
return text;
|
||||
}
|
||||
|
||||
if (text.match(languageMatch)) {
|
||||
// console.log('disqualified for language match')
|
||||
return text;
|
||||
}
|
||||
|
||||
// console.log('qualified for code match');
|
||||
const modelOperations = new ModelOperations();
|
||||
const regexSplit = (await import('./regexSplit.mjs')).default;
|
||||
const parts = regexSplit(text, codeRegex);
|
||||
|
||||
const output = parts.map(async (part) => {
|
||||
if (part.match(codeRegex)) {
|
||||
const code = part.slice(3, -3);
|
||||
const language = await modelOperations.runModel(code);
|
||||
return part.replace(/^```/, `\`\`\`${language[0].languageId}`);
|
||||
} else {
|
||||
// return i > 0 ? '\n' + part : part;
|
||||
return part;
|
||||
}
|
||||
});
|
||||
|
||||
return (await Promise.all(output)).join('');
|
||||
} catch (e) {
|
||||
console.log('Error in detectCode function\n', e);
|
||||
return text;
|
||||
}
|
||||
};
|
||||
|
||||
// const example3 = {
|
||||
// text: "By default, the function generates an 8-character password with uppercase and lowercase letters and digits, but no special characters.\n\nTo use this function, simply call it with the desired arguments. For example:\n\n```\n>>> generate_password()\n'wE5pUxV7'\n>>> generate_password(length=12, special_chars=True)\n'M4v&^gJ*8#qH'\n>>> generate_password(uppercase=False, digits=False)\n'zajyprxr'\n``` \n\nNote that the randomness is used to select characters from the available character sets, but the resulting password is always deterministic given the same inputs. This makes the function useful for generating secure passwords that meet specific requirements."
|
||||
// };
|
||||
|
||||
// const example4 = {
|
||||
// text: 'here\'s a cool function:\n```\nimport random\nimport string\n\ndef generate_password(length=8, uppercase=True, lowercase=True, digits=True, special_chars=False):\n """Generate a random password with specified requirements.\n\n Args:\n length (int): The length of the password. Default is 8.\n uppercase (bool): Whether to include uppercase letters. Default is True.\n lowercase (bool): Whether to include lowercase letters. Default is True.\n digits (bool): Whether to include digits. Default is True.\n special_chars (bool): Whether to include special characters. Default is False.\n\n Returns:\n str: A random password with the specified requirements.\n """\n # Define character sets to use in password generation\n chars = ""\n if uppercase:\n chars += string.ascii_uppercase\n if lowercase:\n chars += string.ascii_lowercase\n if digits:\n chars += string.digits\n if special_chars:\n chars += string.punctuation\n\n # Generate the password\n password = "".join(random.choice(chars) for _ in range(length))\n return password\n```\n\nThis function takes several arguments'
|
||||
// };
|
||||
|
||||
// write an immediately invoked function to test this
|
||||
// (async () => {
|
||||
// const result = await detectCode(example3.text);
|
||||
// console.log(result);
|
||||
// })();
|
||||
|
||||
module.exports = detectCode;
|
||||
@@ -1,15 +1,21 @@
|
||||
const { askClient } = require('./chatgpt-client');
|
||||
const { browserClient } = require('./chatgpt-browser');
|
||||
const customClient = require('./chatgpt-custom');
|
||||
const { askBing } = require('./bingai');
|
||||
const { askClient } = require('./clients/chatgpt-client');
|
||||
const { browserClient } = require('./clients/chatgpt-browser');
|
||||
const { askBing } = require('./clients/bingai');
|
||||
const { askSydney } = require('./clients/sydney');
|
||||
const customClient = require('./clients/chatgpt-custom');
|
||||
const titleConvo = require('./titleConvo');
|
||||
const detectCode = require('./detectCode');
|
||||
const getCitations = require('../lib/parse/getCitations');
|
||||
const citeText = require('../lib/parse/citeText');
|
||||
const detectCode = require('../lib/parse/detectCode');
|
||||
|
||||
module.exports = {
|
||||
askClient,
|
||||
browserClient,
|
||||
customClient,
|
||||
askBing,
|
||||
askSydney,
|
||||
titleConvo,
|
||||
getCitations,
|
||||
citeText,
|
||||
detectCode
|
||||
};
|
||||
@@ -1,24 +1,66 @@
|
||||
const { Configuration, OpenAIApi } = require('openai');
|
||||
const _ = require('lodash');
|
||||
|
||||
const titleConvo = async ({ message, response, model }) => {
|
||||
const configuration = new Configuration({
|
||||
apiKey: process.env.OPENAI_KEY
|
||||
});
|
||||
const openai = new OpenAIApi(configuration);
|
||||
const completion = await openai.createChatCompletion({
|
||||
const proxyEnvToAxiosProxy = proxyString => {
|
||||
if (!proxyString) return null;
|
||||
|
||||
const regex = /^([^:]+):\/\/(?:([^:@]*):?([^:@]*)@)?([^:]+)(?::(\d+))?/;
|
||||
const [, protocol, username, password, host, port] = proxyString.match(regex);
|
||||
const proxyConfig = {
|
||||
protocol,
|
||||
host,
|
||||
port: port ? parseInt(port) : undefined,
|
||||
auth: username && password ? { username, password } : undefined
|
||||
};
|
||||
|
||||
return proxyConfig;
|
||||
};
|
||||
|
||||
const titleConvo = async ({ model, text, response }) => {
|
||||
let title = 'New Chat';
|
||||
|
||||
const request = {
|
||||
model: 'gpt-3.5-turbo',
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
content:
|
||||
'You are a title-generator with one job: titling the conversation provided by a user in title case.'
|
||||
'You are a title-generator with one job: giving a conversation, detect the language and titling the conversation provided by a user in title case, using the same language.'
|
||||
},
|
||||
{ role: 'user', content: `In 5 words or less, summarize the conversation below with a title in title case. Don't refer to the participants of the conversation by name. Do not include punctuation or quotation marks. Your response should be in title case, exclusively containing the title. Conversation:\n\nUser: "${message}"\n\n${model}: "${response}"\n\nTitle: ` },
|
||||
]
|
||||
});
|
||||
{
|
||||
role: 'user',
|
||||
content: `In 5 words or less, summarize the conversation below with a title in title case using the language the user writes in. Don't refer to the participants of the conversation nor the language. Do not include punctuation or quotation marks. Your response should be in title case, exclusively containing the title. Conversation:\n\nUser: "${text}"\n\n${model}: "${JSON.stringify(
|
||||
response?.text
|
||||
)}"\n\nTitle: `
|
||||
}
|
||||
],
|
||||
temperature: 0,
|
||||
presence_penalty: 0,
|
||||
frequency_penalty: 0
|
||||
};
|
||||
|
||||
//eslint-disable-next-line
|
||||
return completion.data.choices[0].message.content.replace(/["\.]/g, '');
|
||||
// console.log('REQUEST', request);
|
||||
|
||||
try {
|
||||
const configuration = new Configuration({
|
||||
apiKey: process.env.OPENAI_KEY
|
||||
});
|
||||
const openai = new OpenAIApi(configuration);
|
||||
const completion = await openai.createChatCompletion(request, {
|
||||
proxy: proxyEnvToAxiosProxy(process.env.PROXY || null)
|
||||
});
|
||||
|
||||
//eslint-disable-next-line
|
||||
title = completion.data.choices[0].message.content.replace(/["\.]/g, '');
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
console.log('There was an issue generating title, see error above');
|
||||
}
|
||||
|
||||
console.log('CONVERSATION TITLE', title);
|
||||
return title;
|
||||
};
|
||||
|
||||
module.exports = titleConvo;
|
||||
const throttledTitleConvo = _.throttle(titleConvo, 1000);
|
||||
|
||||
module.exports = throttledTitleConvo;
|
||||
|
||||
@@ -17,7 +17,7 @@ if (!cached) {
|
||||
cached = global.mongoose = { conn: null, promise: null };
|
||||
}
|
||||
|
||||
async function dbConnect() {
|
||||
async function connectDb() {
|
||||
if (cached.conn) {
|
||||
return cached.conn;
|
||||
}
|
||||
@@ -41,4 +41,4 @@ async function dbConnect() {
|
||||
return cached.conn;
|
||||
}
|
||||
|
||||
module.exports = dbConnect;
|
||||
module.exports = connectDb;
|
||||
70
api/lib/db/indexSync.js
Normal file
70
api/lib/db/indexSync.js
Normal file
@@ -0,0 +1,70 @@
|
||||
const mongoose = require('mongoose');
|
||||
const Conversation = mongoose.models.Conversation;
|
||||
const Message = mongoose.models.Message;
|
||||
const { MeiliSearch } = require('meilisearch');
|
||||
let currentTimeout = null;
|
||||
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
async function indexSync(req, res, next) {
|
||||
try {
|
||||
if (!process.env.MEILI_HOST || !process.env.MEILI_MASTER_KEY || !process.env.SEARCH) {
|
||||
throw new Error('Meilisearch not configured, search will be disabled.');
|
||||
}
|
||||
|
||||
const client = new MeiliSearch({
|
||||
host: process.env.MEILI_HOST,
|
||||
apiKey: process.env.MEILI_MASTER_KEY
|
||||
});
|
||||
|
||||
const { status } = await client.health();
|
||||
// console.log(`Meilisearch: ${status}`);
|
||||
const result = status === 'available' && !!process.env.SEARCH;
|
||||
|
||||
if (!result) {
|
||||
throw new Error('Meilisearch not available');
|
||||
}
|
||||
|
||||
const messageCount = await Message.countDocuments();
|
||||
const convoCount = await Conversation.countDocuments();
|
||||
const messages = await client.index('messages').getStats();
|
||||
const convos = await client.index('convos').getStats();
|
||||
const messagesIndexed = messages.numberOfDocuments;
|
||||
const convosIndexed = convos.numberOfDocuments;
|
||||
|
||||
console.log(`There are ${messageCount} messages in the database, ${messagesIndexed} indexed`);
|
||||
console.log(`There are ${convoCount} convos in the database, ${convosIndexed} indexed`);
|
||||
|
||||
if (messageCount !== messagesIndexed) {
|
||||
console.log('Messages out of sync, indexing');
|
||||
await Message.syncWithMeili();
|
||||
}
|
||||
|
||||
if (convoCount !== convosIndexed) {
|
||||
console.log('Convos out of sync, indexing');
|
||||
await Conversation.syncWithMeili();
|
||||
}
|
||||
} catch (err) {
|
||||
// console.log('in index sync');
|
||||
if (err.message.includes('not found')) {
|
||||
console.log('Creating indices...');
|
||||
currentTimeout = setTimeout(async () => {
|
||||
try {
|
||||
await Message.syncWithMeili();
|
||||
await Conversation.syncWithMeili();
|
||||
} catch (err) {
|
||||
console.error('Trouble creating indices, try restarting the server.');
|
||||
}
|
||||
}, 750);
|
||||
} else {
|
||||
console.error(err);
|
||||
// res.status(500).json({ error: 'Server error' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
process.on('exit', () => {
|
||||
console.log('Clearing sync timeouts before exiting...');
|
||||
clearTimeout(currentTimeout);
|
||||
});
|
||||
|
||||
module.exports = indexSync;
|
||||
63
api/lib/db/migrateDb.js
Normal file
63
api/lib/db/migrateDb.js
Normal file
@@ -0,0 +1,63 @@
|
||||
const mongoose = require('mongoose');
|
||||
const { Conversation, } = require('../../models/Conversation');
|
||||
const { getMessages, } = require('../../models/');
|
||||
|
||||
async function migrateDb() {
|
||||
try {
|
||||
const conversations = await Conversation.find({ model: null }).exec();
|
||||
|
||||
if (!conversations || conversations.length === 0)
|
||||
return { message: '[Migrate] No conversations to migrate' };
|
||||
|
||||
for (let convo of conversations) {
|
||||
const messages = await getMessages({
|
||||
conversationId: convo.conversationId,
|
||||
messageId: { $exists: false }
|
||||
});
|
||||
|
||||
let model;
|
||||
let oldId;
|
||||
const promises = [];
|
||||
messages.forEach((message, i) => {
|
||||
const msgObj = message.toObject();
|
||||
const newId = msgObj.id;
|
||||
if (i === 0) {
|
||||
message.parentMessageId = '00000000-0000-0000-0000-000000000000';
|
||||
} else {
|
||||
message.parentMessageId = oldId;
|
||||
}
|
||||
|
||||
oldId = newId;
|
||||
message.messageId = newId;
|
||||
if (message.sender.toLowerCase() !== 'user' && !model) {
|
||||
model = message.sender.toLowerCase();
|
||||
}
|
||||
|
||||
if (message.sender.toLowerCase() === 'user') {
|
||||
message.isCreatedByUser = true;
|
||||
}
|
||||
|
||||
promises.push(message.save());
|
||||
});
|
||||
await Promise.all(promises);
|
||||
|
||||
await Conversation.findOneAndUpdate(
|
||||
{ conversationId: convo.conversationId },
|
||||
{ model },
|
||||
{ new: true }
|
||||
).exec();
|
||||
}
|
||||
|
||||
try {
|
||||
await mongoose.connection.db.collection('messages').dropIndex('id_1');
|
||||
} catch (error) {
|
||||
console.log("[Migrate] Index doesn't exist or already dropped");
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
return { message: '[Migrate] Error migrating conversations' };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
module.exports = migrateDb;
|
||||
31
api/lib/parse/citeText.js
Normal file
31
api/lib/parse/citeText.js
Normal file
@@ -0,0 +1,31 @@
|
||||
const citationRegex = /\[\^\d+?\^]/g;
|
||||
|
||||
const citeText = (res, noLinks = false) => {
|
||||
let result = res.text || res;
|
||||
const citations = Array.from(new Set(result.match(citationRegex)));
|
||||
if (citations?.length === 0) return result;
|
||||
|
||||
if (noLinks) {
|
||||
citations.forEach((citation) => {
|
||||
const digit = citation.match(/\d+?/g)[0];
|
||||
// result = result.replaceAll(citation, `<sup>[${digit}](#) </sup>`);
|
||||
result = result.replaceAll(citation, `<sup>[${digit}](#) </sup>`);
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
let sources = res.details.sourceAttributions;
|
||||
if (sources?.length === 0) return result;
|
||||
sources = sources.map((source) => source.seeMoreUrl);
|
||||
|
||||
citations.forEach((citation) => {
|
||||
const digit = citation.match(/\d+?/g)[0];
|
||||
result = result.replaceAll(citation, `<sup>[${digit}](${sources[digit - 1]}) </sup>`);
|
||||
// result = result.replaceAll(citation, `<sup>[${digit}](${sources[digit - 1]}) </sup>`);
|
||||
});
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
module.exports = citeText;
|
||||
52
api/lib/parse/detectCode.js
Normal file
52
api/lib/parse/detectCode.js
Normal file
@@ -0,0 +1,52 @@
|
||||
const { ModelOperations } = require('@vscode/vscode-languagedetection');
|
||||
const languages = require('./languages.js');
|
||||
const codeRegex = /(```[\s\S]*?```)/g;
|
||||
// const languageMatch = /```(\w+)/;
|
||||
const replaceRegex = /```\w+\n/g;
|
||||
|
||||
const detectCode = async (input) => {
|
||||
try {
|
||||
let text = input;
|
||||
if (!text.match(codeRegex)) {
|
||||
return text;
|
||||
}
|
||||
|
||||
const langMatches = text.match(replaceRegex);
|
||||
|
||||
if (langMatches?.length > 0) {
|
||||
langMatches.forEach(match => {
|
||||
let lang = match.split('```')[1].trim();
|
||||
|
||||
if (languages.has(lang)) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[detectCode.js] replacing', match, 'with', '```shell');
|
||||
text = text.replace(match, '```shell\n');
|
||||
});
|
||||
|
||||
return text;
|
||||
}
|
||||
|
||||
const modelOperations = new ModelOperations();
|
||||
const regexSplit = (await import('./regexSplit.mjs')).default;
|
||||
const parts = regexSplit(text, codeRegex);
|
||||
|
||||
const output = parts.map(async (part) => {
|
||||
if (part.match(codeRegex)) {
|
||||
const code = part.slice(3, -3);
|
||||
let lang = (await modelOperations.runModel(code))[0].languageId;
|
||||
return part.replace(/^```/, `\`\`\`${languages.has(lang) ? lang : 'shell'}`);
|
||||
} else {
|
||||
return part;
|
||||
}
|
||||
});
|
||||
|
||||
return (await Promise.all(output)).join('');
|
||||
} catch (e) {
|
||||
console.log('Error in detectCode function\n', e);
|
||||
return input;
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = detectCode;
|
||||
13
api/lib/parse/getCitations.js
Normal file
13
api/lib/parse/getCitations.js
Normal file
@@ -0,0 +1,13 @@
|
||||
// const regex = / \[\d+\..*?\]\(.*?\)/g;
|
||||
const regex = / \[.*?]\(.*?\)/g;
|
||||
|
||||
const getCitations = (res) => {
|
||||
const textBlocks = res.details.adaptiveCards[0].body;
|
||||
if (!textBlocks) return '';
|
||||
let links = textBlocks[textBlocks.length - 1]?.text.match(regex);
|
||||
if (links?.length === 0 || !links) return '';
|
||||
links = links.map((link) => link.trim());
|
||||
return links.join('\n');
|
||||
};
|
||||
|
||||
module.exports = getCitations;
|
||||
318
api/lib/parse/languages.js
Normal file
318
api/lib/parse/languages.js
Normal file
@@ -0,0 +1,318 @@
|
||||
const languages = new Set([
|
||||
'adoc',
|
||||
'apacheconf',
|
||||
'arm',
|
||||
'as',
|
||||
'asc',
|
||||
'atom',
|
||||
'bat',
|
||||
'bf',
|
||||
'bind',
|
||||
'c++',
|
||||
'capnp',
|
||||
'cc',
|
||||
'clj',
|
||||
'cls',
|
||||
'cmake.in',
|
||||
'cmd',
|
||||
'coffee',
|
||||
'console',
|
||||
'cr',
|
||||
'craftcms',
|
||||
'crm',
|
||||
'cs',
|
||||
'cson',
|
||||
'cts',
|
||||
'cxx',
|
||||
'dfm',
|
||||
'docker',
|
||||
'dst',
|
||||
'erl',
|
||||
'f90',
|
||||
'f95',
|
||||
'fs',
|
||||
'gawk',
|
||||
'gemspec',
|
||||
'gms',
|
||||
'golang',
|
||||
'gololang',
|
||||
'gss',
|
||||
'gyp',
|
||||
'h',
|
||||
'h++',
|
||||
'hbs',
|
||||
'hh',
|
||||
'hpp',
|
||||
'hs',
|
||||
'html',
|
||||
'html.handlebars',
|
||||
'html.hbs',
|
||||
'https',
|
||||
'hx',
|
||||
'hxx',
|
||||
'hylang',
|
||||
'i7',
|
||||
'iced',
|
||||
'ino',
|
||||
'instances',
|
||||
'irb',
|
||||
'jinja',
|
||||
'js',
|
||||
'jsp',
|
||||
'jsx',
|
||||
'julia-repl',
|
||||
'kdb',
|
||||
'kt',
|
||||
'lassoscript',
|
||||
'ls',
|
||||
'ls',
|
||||
'mak',
|
||||
'make',
|
||||
'mawk',
|
||||
'md',
|
||||
'mipsasm',
|
||||
'mk',
|
||||
'mkd',
|
||||
'mkdown',
|
||||
'ml',
|
||||
'ml',
|
||||
'mm',
|
||||
'mma',
|
||||
'moon',
|
||||
'mts',
|
||||
'nawk',
|
||||
'nc',
|
||||
'nginxconf',
|
||||
'nimrod',
|
||||
'objc',
|
||||
'obj-c',
|
||||
'obj-c++',
|
||||
'objective-c++',
|
||||
'osascript',
|
||||
'pas',
|
||||
'pascal',
|
||||
'patch',
|
||||
'pcmk',
|
||||
'pf.conf',
|
||||
'pl',
|
||||
'plist',
|
||||
'pm',
|
||||
'podspec',
|
||||
'postgres',
|
||||
'postgresql',
|
||||
'pp',
|
||||
'ps',
|
||||
'ps1',
|
||||
'py',
|
||||
'pycon',
|
||||
'rb',
|
||||
're',
|
||||
'rs',
|
||||
'rss',
|
||||
'sas',
|
||||
'scad',
|
||||
'sci',
|
||||
'sh',
|
||||
'st',
|
||||
'stanfuncs',
|
||||
'step',
|
||||
'stp',
|
||||
'styl',
|
||||
'svg',
|
||||
'tao',
|
||||
'text',
|
||||
'thor',
|
||||
'tk',
|
||||
'toml',
|
||||
'ts',
|
||||
'tsx',
|
||||
'txt',
|
||||
'v',
|
||||
'vb',
|
||||
'vbs',
|
||||
'wl',
|
||||
'x++',
|
||||
'xhtml',
|
||||
'xjb',
|
||||
'xls',
|
||||
'xlsx',
|
||||
'xpath',
|
||||
'xq',
|
||||
'xsd',
|
||||
'xsl',
|
||||
'yaml',
|
||||
'zep',
|
||||
'zone',
|
||||
'zsh',
|
||||
'1c',
|
||||
'abnf',
|
||||
'accesslog',
|
||||
'actionscript',
|
||||
'ada',
|
||||
'angelscript',
|
||||
'apache',
|
||||
'applescript',
|
||||
'arcade',
|
||||
'arduino',
|
||||
'armasm',
|
||||
'asciidoc',
|
||||
'aspectj',
|
||||
'autohotkey',
|
||||
'autoit',
|
||||
'avrasm',
|
||||
'awk',
|
||||
'axapta',
|
||||
'bash',
|
||||
'basic',
|
||||
'bnf',
|
||||
'brainfuck',
|
||||
'c',
|
||||
'cal',
|
||||
'capnproto',
|
||||
'clojure',
|
||||
'cmake',
|
||||
'coffeescript',
|
||||
'coq',
|
||||
'cos',
|
||||
'cpp',
|
||||
'crmsh',
|
||||
'crystal',
|
||||
'csharp',
|
||||
'csp',
|
||||
'css',
|
||||
'd',
|
||||
'dart',
|
||||
'diff',
|
||||
'django',
|
||||
'dns',
|
||||
'dockerfile',
|
||||
'dos',
|
||||
'dpr',
|
||||
'dsconfig',
|
||||
'dts',
|
||||
'dust',
|
||||
'ebnf',
|
||||
'elixir',
|
||||
'elm',
|
||||
'erlang',
|
||||
'excel',
|
||||
'fix',
|
||||
'fortran',
|
||||
'fsharp',
|
||||
'gams',
|
||||
'gauss',
|
||||
'gcode',
|
||||
'gherkin',
|
||||
'glsl',
|
||||
'go',
|
||||
'golo',
|
||||
'gradle',
|
||||
'graph',
|
||||
'graphql',
|
||||
'groovy',
|
||||
'haml',
|
||||
'handlebars',
|
||||
'haskell',
|
||||
'haxe',
|
||||
'http',
|
||||
'hy',
|
||||
'inform7',
|
||||
'ini',
|
||||
'irpf90',
|
||||
'java',
|
||||
'javascript',
|
||||
'json',
|
||||
'julia',
|
||||
'k',
|
||||
'kotlin',
|
||||
'lasso',
|
||||
'ldif',
|
||||
'leaf',
|
||||
'less',
|
||||
'lisp',
|
||||
'livecodeserver',
|
||||
'livescript',
|
||||
'lua',
|
||||
'makefile',
|
||||
'markdown',
|
||||
'mathematica',
|
||||
'matlab',
|
||||
'maxima',
|
||||
'mel',
|
||||
'mercury',
|
||||
'mips',
|
||||
'mizar',
|
||||
'mojolicious',
|
||||
'monkey',
|
||||
'moonscript',
|
||||
'n1ql',
|
||||
'nginx',
|
||||
'nim',
|
||||
'nix',
|
||||
'nsis',
|
||||
'objectivec',
|
||||
'ocaml',
|
||||
'openscad',
|
||||
'oxygene',
|
||||
'p21',
|
||||
'parser3',
|
||||
'perl',
|
||||
'pf',
|
||||
'pgsql',
|
||||
'php',
|
||||
'plaintext',
|
||||
'pony',
|
||||
'powershell',
|
||||
'processing',
|
||||
'profile',
|
||||
'prolog',
|
||||
'properties',
|
||||
'protobuf',
|
||||
'puppet',
|
||||
'python',
|
||||
'python-repl',
|
||||
'qml',
|
||||
'r',
|
||||
'reasonml',
|
||||
'rib',
|
||||
'rsl',
|
||||
'ruby',
|
||||
'ruleslanguage',
|
||||
'rust',
|
||||
'SAS',
|
||||
'scala' ,
|
||||
'scheme',
|
||||
'scilab',
|
||||
'scss',
|
||||
'shell',
|
||||
'smali',
|
||||
'smalltalk',
|
||||
'sml',
|
||||
'sql',
|
||||
'stan',
|
||||
'stata',
|
||||
'stylus',
|
||||
'subunit',
|
||||
'swift',
|
||||
'tap',
|
||||
'tcl',
|
||||
'tex',
|
||||
'thrift',
|
||||
'tp',
|
||||
'twig',
|
||||
'typescript',
|
||||
'vala',
|
||||
'vbnet',
|
||||
'vbscript',
|
||||
'verilog',
|
||||
'vhdl',
|
||||
'vim',
|
||||
'x86asm',
|
||||
'xl',
|
||||
'xml',
|
||||
'xquery',
|
||||
'yml',
|
||||
'zephir',
|
||||
]);
|
||||
|
||||
module.exports = languages;
|
||||
29
api/lib/utils/mergeSort.js
Normal file
29
api/lib/utils/mergeSort.js
Normal file
@@ -0,0 +1,29 @@
|
||||
function mergeSort(arr, compareFn) {
|
||||
if (arr.length <= 1) {
|
||||
return arr;
|
||||
}
|
||||
|
||||
const mid = Math.floor(arr.length / 2);
|
||||
const leftArr = arr.slice(0, mid);
|
||||
const rightArr = arr.slice(mid);
|
||||
|
||||
return merge(mergeSort(leftArr, compareFn), mergeSort(rightArr, compareFn), compareFn);
|
||||
}
|
||||
|
||||
function merge(leftArr, rightArr, compareFn) {
|
||||
const result = [];
|
||||
let leftIndex = 0;
|
||||
let rightIndex = 0;
|
||||
|
||||
while (leftIndex < leftArr.length && rightIndex < rightArr.length) {
|
||||
if (compareFn(leftArr[leftIndex], rightArr[rightIndex]) < 0) {
|
||||
result.push(leftArr[leftIndex++]);
|
||||
} else {
|
||||
result.push(rightArr[rightIndex++]);
|
||||
}
|
||||
}
|
||||
|
||||
return result.concat(leftArr.slice(leftIndex)).concat(rightArr.slice(rightIndex));
|
||||
}
|
||||
|
||||
module.exports = mergeSort;
|
||||
15
api/lib/utils/misc.js
Normal file
15
api/lib/utils/misc.js
Normal file
@@ -0,0 +1,15 @@
|
||||
const cleanUpPrimaryKeyValue = (value) => {
|
||||
// For Bing convoId handling
|
||||
return value.replace(/--/g, '|');
|
||||
};
|
||||
|
||||
function replaceSup(text) {
|
||||
if (!text.includes('<sup>')) return text;
|
||||
const replacedText = text.replace(/<sup>/g, '^').replace(/\s+<\/sup>/g, '^');
|
||||
return replacedText;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
cleanUpPrimaryKeyValue,
|
||||
replaceSup
|
||||
};
|
||||
59
api/lib/utils/reduceHits.js
Normal file
59
api/lib/utils/reduceHits.js
Normal file
@@ -0,0 +1,59 @@
|
||||
const mergeSort = require('./mergeSort');
|
||||
const { cleanUpPrimaryKeyValue } = require('./misc');
|
||||
|
||||
function reduceMessages(hits) {
|
||||
const counts = {};
|
||||
|
||||
for (const hit of hits) {
|
||||
if (!counts[hit.conversationId]) {
|
||||
counts[hit.conversationId] = 1;
|
||||
} else {
|
||||
counts[hit.conversationId]++;
|
||||
}
|
||||
}
|
||||
|
||||
const result = [];
|
||||
|
||||
for (const [conversationId, count] of Object.entries(counts)) {
|
||||
result.push({
|
||||
conversationId,
|
||||
count
|
||||
});
|
||||
}
|
||||
|
||||
return mergeSort(result, (a, b) => b.count - a.count);
|
||||
}
|
||||
|
||||
function reduceHits(hits, titles = []) {
|
||||
const counts = {};
|
||||
const titleMap = {};
|
||||
const convos = [...hits, ...titles];
|
||||
|
||||
for (const convo of convos) {
|
||||
const currentId = cleanUpPrimaryKeyValue(convo.conversationId);
|
||||
if (!counts[currentId]) {
|
||||
counts[currentId] = 1;
|
||||
} else {
|
||||
counts[currentId]++;
|
||||
}
|
||||
|
||||
if (convo.title) {
|
||||
// titleMap[currentId] = convo._formatted.title;
|
||||
titleMap[currentId] = convo.title;
|
||||
}
|
||||
}
|
||||
|
||||
const result = [];
|
||||
|
||||
for (const [conversationId, count] of Object.entries(counts)) {
|
||||
result.push({
|
||||
conversationId,
|
||||
count,
|
||||
title: titleMap[conversationId] ? titleMap[conversationId] : null
|
||||
});
|
||||
}
|
||||
|
||||
return mergeSort(result, (a, b) => b.count - a.count);
|
||||
}
|
||||
|
||||
module.exports = { reduceMessages, reduceHits };
|
||||
84
api/models/Config.js
Normal file
84
api/models/Config.js
Normal file
@@ -0,0 +1,84 @@
|
||||
const mongoose = require('mongoose');
|
||||
const major = [0, 0];
|
||||
const minor = [0, 0];
|
||||
const patch = [0, 5];
|
||||
|
||||
const configSchema = mongoose.Schema(
|
||||
{
|
||||
tag: {
|
||||
type: String,
|
||||
required: true,
|
||||
validate: {
|
||||
validator: function (tag) {
|
||||
const [part1, part2, part3] = tag.replace('v', '').split('.').map(Number);
|
||||
|
||||
// Check if all parts are numbers
|
||||
if (isNaN(part1) || isNaN(part2) || isNaN(part3)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if all parts are within their respective ranges
|
||||
if (part1 < major[0] || part1 > major[1]) {
|
||||
return false;
|
||||
}
|
||||
if (part2 < minor[0] || part2 > minor[1]) {
|
||||
return false;
|
||||
}
|
||||
if (part3 < patch[0] || part3 > patch[1]) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
message: 'Invalid tag value'
|
||||
}
|
||||
},
|
||||
searchEnabled: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
usersEnabled: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
startupCounts: {
|
||||
type: Number,
|
||||
default: 0
|
||||
}
|
||||
},
|
||||
{ timestamps: true }
|
||||
);
|
||||
|
||||
// Instance method
|
||||
configSchema.methods.incrementCount = function () {
|
||||
this.startupCounts += 1;
|
||||
};
|
||||
|
||||
// Static methods
|
||||
configSchema.statics.findByTag = async function (tag) {
|
||||
return await this.findOne({ tag });
|
||||
};
|
||||
|
||||
configSchema.statics.updateByTag = async function (tag, update) {
|
||||
return await this.findOneAndUpdate({ tag }, update, { new: true });
|
||||
};
|
||||
|
||||
const Config = mongoose.models.Config || mongoose.model('Config', configSchema);
|
||||
|
||||
module.exports = {
|
||||
getConfigs: async (filter) => {
|
||||
try {
|
||||
return await Config.find(filter).exec();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return { config: 'Error getting configs' };
|
||||
}
|
||||
},
|
||||
deleteConfigs: async (filter) => {
|
||||
try {
|
||||
return await Config.deleteMany(filter).exec();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return { config: 'Error deleting configs' };
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -1,60 +1,40 @@
|
||||
const mongoose = require('mongoose');
|
||||
// const { Conversation } = require('./plugins');
|
||||
const Conversation = require('./schema/convoSchema');
|
||||
const { getMessages, deleteMessages } = require('./Message');
|
||||
|
||||
const convoSchema = mongoose.Schema({
|
||||
conversationId: {
|
||||
type: String,
|
||||
unique: true,
|
||||
required: true
|
||||
},
|
||||
parentMessageId: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
default: 'New conversation'
|
||||
},
|
||||
conversationSignature: {
|
||||
type: String
|
||||
},
|
||||
clientId: {
|
||||
type: String
|
||||
},
|
||||
invocationId: {
|
||||
type: String
|
||||
},
|
||||
chatGptLabel: {
|
||||
type: String
|
||||
},
|
||||
promptPrefix: {
|
||||
type: String
|
||||
},
|
||||
model: {
|
||||
type: String
|
||||
},
|
||||
suggestions: [{ type: String }],
|
||||
messages: [{ type: mongoose.Schema.Types.ObjectId, ref: 'Message' }],
|
||||
created: {
|
||||
type: Date,
|
||||
default: Date.now
|
||||
const getConvo = async (user, conversationId) => {
|
||||
try {
|
||||
return await Conversation.findOne({ user, conversationId }).exec();
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
return { message: 'Error getting single conversation' };
|
||||
}
|
||||
});
|
||||
|
||||
const Conversation =
|
||||
mongoose.models.Conversation || mongoose.model('Conversation', convoSchema);
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
saveConvo: async ({ conversationId, title, ...convo }) => {
|
||||
Conversation,
|
||||
saveConvo: async (user, { conversationId, newConversationId, title, ...convo }) => {
|
||||
try {
|
||||
const messages = await getMessages({ conversationId });
|
||||
const update = { ...convo, messages };
|
||||
if (title) {
|
||||
update.title = title;
|
||||
update.user = user;
|
||||
}
|
||||
if (newConversationId) {
|
||||
update.conversationId = newConversationId;
|
||||
}
|
||||
if (!update.jailbreakConversationId) {
|
||||
update.jailbreakConversationId = null;
|
||||
}
|
||||
if (update.model !== 'chatgptCustom' && update.chatGptLabel && update.promptPrefix) {
|
||||
console.log('Validation error: resetting chatgptCustom fields', update);
|
||||
update.chatGptLabel = null;
|
||||
update.promptPrefix = null;
|
||||
}
|
||||
|
||||
return await Conversation.findOneAndUpdate(
|
||||
{ conversationId },
|
||||
{ conversationId: conversationId, user },
|
||||
{ $set: update },
|
||||
{ new: true, upsert: true }
|
||||
).exec();
|
||||
@@ -63,45 +43,112 @@ module.exports = {
|
||||
return { message: 'Error saving conversation' };
|
||||
}
|
||||
},
|
||||
updateConvo: async ({ conversationId, ...update }) => {
|
||||
updateConvo: async (user, { conversationId, ...update }) => {
|
||||
try {
|
||||
return await Conversation.findOneAndUpdate({ conversationId }, update, {
|
||||
new: true
|
||||
}).exec();
|
||||
return await Conversation.findOneAndUpdate(
|
||||
{ conversationId: conversationId, user },
|
||||
update,
|
||||
{
|
||||
new: true
|
||||
}
|
||||
).exec();
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
return { message: 'Error updating conversation' };
|
||||
}
|
||||
},
|
||||
// getConvos: async () => await Conversation.find({}).sort({ created: -1 }).exec(),
|
||||
getConvos: async (pageNumber = 1, pageSize = 12) => {
|
||||
getConvosByPage: async (user, pageNumber = 1, pageSize = 12) => {
|
||||
try {
|
||||
const skip = (pageNumber - 1) * pageSize;
|
||||
// const limit = pageNumber * pageSize;
|
||||
|
||||
const conversations = await Conversation.find({})
|
||||
.sort({ created: -1 })
|
||||
.skip(skip)
|
||||
// .limit(limit)
|
||||
const totalConvos = (await Conversation.countDocuments({ user })) || 1;
|
||||
const totalPages = Math.ceil(totalConvos / pageSize);
|
||||
const convos = await Conversation.find({ user })
|
||||
.sort({ createdAt: -1, created: -1 })
|
||||
.skip((pageNumber - 1) * pageSize)
|
||||
.limit(pageSize)
|
||||
.exec();
|
||||
|
||||
return conversations;
|
||||
return { conversations: convos, pages: totalPages, pageNumber, pageSize };
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
return { message: 'Error getting conversations' };
|
||||
}
|
||||
},
|
||||
getConvo: async (conversationId) => {
|
||||
getConvosQueried: async (user, convoIds, pageNumber = 1, pageSize = 12) => {
|
||||
try {
|
||||
return await Conversation.findOne({ conversationId }).exec();
|
||||
if (!convoIds || convoIds.length === 0) {
|
||||
return { conversations: [], pages: 1, pageNumber, pageSize };
|
||||
}
|
||||
|
||||
const cache = {};
|
||||
const convoMap = {};
|
||||
const promises = [];
|
||||
// will handle a syncing solution soon
|
||||
const deletedConvoIds = [];
|
||||
|
||||
convoIds.forEach(convo =>
|
||||
promises.push(
|
||||
Conversation.findOne({
|
||||
user,
|
||||
conversationId: convo.conversationId,
|
||||
}).exec()
|
||||
)
|
||||
);
|
||||
|
||||
const results = (await Promise.all(promises)).filter((convo, i) => {
|
||||
if (!convo) {
|
||||
deletedConvoIds.push(convoIds[i].conversationId);
|
||||
return false;
|
||||
} else {
|
||||
const page = Math.floor(i / pageSize) + 1;
|
||||
if (!cache[page]) {
|
||||
cache[page] = [];
|
||||
}
|
||||
cache[page].push(convo);
|
||||
convoMap[convo.conversationId] = convo;
|
||||
return true;
|
||||
}
|
||||
});
|
||||
|
||||
// const startIndex = (pageNumber - 1) * pageSize;
|
||||
// const convos = results.slice(startIndex, startIndex + pageSize);
|
||||
const totalPages = Math.ceil(results.length / pageSize);
|
||||
cache.pages = totalPages;
|
||||
cache.pageSize = pageSize;
|
||||
return {
|
||||
cache,
|
||||
conversations: cache[pageNumber] || [],
|
||||
pages: totalPages || 1,
|
||||
pageNumber,
|
||||
pageSize,
|
||||
// will handle a syncing solution soon
|
||||
filter: new Set(deletedConvoIds),
|
||||
convoMap
|
||||
};
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
return { message: 'Error getting single conversation' };
|
||||
return { message: 'Error fetching conversations' };
|
||||
}
|
||||
},
|
||||
deleteConvos: async (filter) => {
|
||||
let deleteCount = await Conversation.deleteMany(filter).exec();
|
||||
getConvo,
|
||||
/* chore: this method is not properly error handled */
|
||||
getConvoTitle: async (user, conversationId) => {
|
||||
try {
|
||||
const convo = await getConvo(user, conversationId);
|
||||
/* ChatGPT Browser was triggering error here due to convo being saved later */
|
||||
if (convo && !convo.title) {
|
||||
return null;
|
||||
} else {
|
||||
// TypeError: Cannot read properties of null (reading 'title')
|
||||
return convo?.title || 'New Chat';
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
return 'Error getting conversation title';
|
||||
}
|
||||
},
|
||||
deleteConvos: async (user, filter) => {
|
||||
let deleteCount = await Conversation.deleteMany({ ...filter, user }).exec();
|
||||
console.log('deleteCount', deleteCount);
|
||||
deleteCount.messages = await deleteMessages(filter);
|
||||
return deleteCount;
|
||||
}
|
||||
|
||||
@@ -12,20 +12,20 @@ const customGptSchema = mongoose.Schema({
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
created: {
|
||||
type: Date,
|
||||
default: Date.now
|
||||
}
|
||||
});
|
||||
user: {
|
||||
type: String
|
||||
},
|
||||
}, { timestamps: true });
|
||||
|
||||
const CustomGpt = mongoose.models.CustomGpt || mongoose.model('CustomGpt', customGptSchema);
|
||||
|
||||
const createCustomGpt = async ({ chatGptLabel, promptPrefix, value }) => {
|
||||
const createCustomGpt = async ({ chatGptLabel, promptPrefix, value, user }) => {
|
||||
try {
|
||||
await CustomGpt.create({
|
||||
chatGptLabel,
|
||||
promptPrefix,
|
||||
value
|
||||
value,
|
||||
user
|
||||
});
|
||||
return { chatGptLabel, promptPrefix, value };
|
||||
} catch (error) {
|
||||
@@ -35,22 +35,22 @@ const createCustomGpt = async ({ chatGptLabel, promptPrefix, value }) => {
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
getCustomGpts: async (filter) => {
|
||||
getCustomGpts: async (user, filter) => {
|
||||
try {
|
||||
return await CustomGpt.find(filter).exec();
|
||||
return await CustomGpt.find({ ...filter, user }).exec();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return { customGpt: 'Error getting customGpts' };
|
||||
}
|
||||
},
|
||||
updateCustomGpt: async ({ value, ...update }) => {
|
||||
updateCustomGpt: async (user, { value, ...update }) => {
|
||||
try {
|
||||
const customGpt = await CustomGpt.findOne({ value }).exec();
|
||||
const customGpt = await CustomGpt.findOne({ value, user }).exec();
|
||||
|
||||
if (!customGpt) {
|
||||
return await createCustomGpt({ value, ...update });
|
||||
return await createCustomGpt({ value, ...update, user });
|
||||
} else {
|
||||
return await CustomGpt.findOneAndUpdate({ value }, update, {
|
||||
return await CustomGpt.findOneAndUpdate({ value, user }, update, {
|
||||
new: true,
|
||||
upsert: true
|
||||
}).exec();
|
||||
@@ -60,9 +60,9 @@ module.exports = {
|
||||
return { message: 'Error updating customGpt' };
|
||||
}
|
||||
},
|
||||
updateByLabel: async ({ prevLabel, ...update }) => {
|
||||
updateByLabel: async (user, { prevLabel, ...update }) => {
|
||||
try {
|
||||
return await CustomGpt.findOneAndUpdate({ chatGptLabel: prevLabel }, update, {
|
||||
return await CustomGpt.findOneAndUpdate({ chatGptLabel: prevLabel, user }, update, {
|
||||
new: true,
|
||||
upsert: true
|
||||
}).exec();
|
||||
@@ -71,9 +71,9 @@ module.exports = {
|
||||
return { message: 'Error updating customGpt' };
|
||||
}
|
||||
},
|
||||
deleteCustomGpts: async (filter) => {
|
||||
deleteCustomGpts: async (user, filter) => {
|
||||
try {
|
||||
return await CustomGpt.deleteMany(filter).exec();
|
||||
return await CustomGpt.deleteMany({ ...filter, user }).exec();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return { customGpt: 'Error deleting customGpts' };
|
||||
|
||||
@@ -1,64 +1,53 @@
|
||||
const mongoose = require('mongoose');
|
||||
|
||||
const messageSchema = mongoose.Schema({
|
||||
id: {
|
||||
type: String,
|
||||
unique: true,
|
||||
required: true
|
||||
},
|
||||
conversationId: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
conversationSignature: {
|
||||
type: String,
|
||||
// required: true
|
||||
},
|
||||
clientId: {
|
||||
type: String,
|
||||
},
|
||||
invocationId: {
|
||||
type: String,
|
||||
},
|
||||
parentMessageId: {
|
||||
type: String,
|
||||
// required: true
|
||||
},
|
||||
sender: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
text: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
created: {
|
||||
type: Date,
|
||||
default: Date.now
|
||||
}
|
||||
});
|
||||
|
||||
const Message = mongoose.models.Message || mongoose.model('Message', messageSchema);
|
||||
|
||||
const Message = require('./schema/messageSchema');
|
||||
module.exports = {
|
||||
saveMessage: async ({ id, conversationId, parentMessageId, sender, text }) => {
|
||||
Message,
|
||||
saveMessage: async ({ messageId, conversationId, parentMessageId, sender, text, isCreatedByUser=false, error }) => {
|
||||
try {
|
||||
await Message.create({
|
||||
id,
|
||||
await Message.findOneAndUpdate({ messageId }, {
|
||||
conversationId,
|
||||
parentMessageId,
|
||||
sender,
|
||||
text
|
||||
});
|
||||
return { id, conversationId, parentMessageId, sender, text };
|
||||
text,
|
||||
isCreatedByUser,
|
||||
error
|
||||
}, { upsert: true, new: true });
|
||||
return { messageId, conversationId, parentMessageId, sender, text, isCreatedByUser };
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return { message: 'Error saving message' };
|
||||
}
|
||||
},
|
||||
saveBingMessage: async ({ messageId, oldMessageId = messageId, conversationId, parentMessageId, sender, text, isCreatedByUser=false, error }) => {
|
||||
try {
|
||||
await Message.findOneAndUpdate({ messageId: oldMessageId }, {
|
||||
messageId,
|
||||
conversationId,
|
||||
parentMessageId,
|
||||
sender,
|
||||
text,
|
||||
isCreatedByUser,
|
||||
error
|
||||
}, { upsert: true, new: true });
|
||||
return { messageId, conversationId, parentMessageId, sender, text, isCreatedByUser };
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return { message: 'Error saving message' };
|
||||
}
|
||||
},
|
||||
deleteMessagesSince: async ({ messageId, conversationId }) => {
|
||||
try {
|
||||
const message = await Message.findOne({ messageId }).exec()
|
||||
|
||||
if (message)
|
||||
return await Message.find({ conversationId }).deleteMany({ createdAt: { $gt: message.createdAt } }).exec();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return { message: 'Error deleting messages' };
|
||||
}
|
||||
},
|
||||
getMessages: async (filter) => {
|
||||
try {
|
||||
return await Message.find(filter).exec()
|
||||
return await Message.find(filter).sort({createdAt: 1}).exec()
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return { message: 'Error getting messages' };
|
||||
|
||||
@@ -12,11 +12,7 @@ const promptSchema = mongoose.Schema({
|
||||
category: {
|
||||
type: String,
|
||||
},
|
||||
created: {
|
||||
type: Date,
|
||||
default: Date.now
|
||||
}
|
||||
});
|
||||
}, { timestamps: true });
|
||||
|
||||
const Prompt = mongoose.models.Prompt || mongoose.model('Prompt', promptSchema);
|
||||
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
const { saveMessage, deleteMessages } = require('./Message');
|
||||
const { getMessages, saveMessage, saveBingMessage, deleteMessagesSince, deleteMessages } = require('./Message');
|
||||
const { getCustomGpts, updateCustomGpt, updateByLabel, deleteCustomGpts } = require('./CustomGpt');
|
||||
const { getConvo, saveConvo } = require('./Conversation');
|
||||
const { getConvoTitle, getConvo, saveConvo } = require('./Conversation');
|
||||
|
||||
module.exports = {
|
||||
getMessages,
|
||||
saveMessage,
|
||||
saveBingMessage,
|
||||
deleteMessagesSince,
|
||||
deleteMessages,
|
||||
getConvoTitle,
|
||||
getConvo,
|
||||
saveConvo,
|
||||
getCustomGpts,
|
||||
|
||||
211
api/models/plugins/mongoMeili.js
Normal file
211
api/models/plugins/mongoMeili.js
Normal file
@@ -0,0 +1,211 @@
|
||||
const mongoose = require('mongoose');
|
||||
const { MeiliSearch } = require('meilisearch');
|
||||
const { cleanUpPrimaryKeyValue } = require('../../lib/utils/misc');
|
||||
const _ = require('lodash');
|
||||
|
||||
const validateOptions = function (options) {
|
||||
const requiredKeys = ['host', 'apiKey', 'indexName'];
|
||||
requiredKeys.forEach((key) => {
|
||||
if (!options[key]) throw new Error(`Missing mongoMeili Option: ${key}`);
|
||||
});
|
||||
};
|
||||
|
||||
const createMeiliMongooseModel = function ({ index, indexName, client, attributesToIndex }) {
|
||||
// console.log('attributesToIndex', attributesToIndex);
|
||||
const primaryKey = attributesToIndex[0];
|
||||
// MeiliMongooseModel is of type Mongoose.Model
|
||||
class MeiliMongooseModel {
|
||||
// Clear Meili index
|
||||
static async clearMeiliIndex() {
|
||||
await index.delete();
|
||||
// await index.deleteAllDocuments();
|
||||
await this.collection.updateMany(
|
||||
{ _meiliIndex: true },
|
||||
{ $set: { _meiliIndex: false } }
|
||||
);
|
||||
}
|
||||
|
||||
static async resetIndex() {
|
||||
await this.clearMeiliIndex();
|
||||
await client.createIndex(indexName, { primaryKey });
|
||||
}
|
||||
// Clear Meili index
|
||||
// Push a mongoDB collection to Meili index
|
||||
static async syncWithMeili() {
|
||||
await this.resetIndex();
|
||||
// const docs = await this.find();
|
||||
const docs = await this.find({ _meiliIndex: { $in: [null, false] } });
|
||||
console.log('docs', docs.length);
|
||||
await Promise.all(
|
||||
docs.map(function (doc) {
|
||||
return doc.addObjectToMeili();
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// Set one or more settings of the meili index
|
||||
static async setMeiliIndexSettings(settings) {
|
||||
return await index.updateSettings(settings);
|
||||
}
|
||||
|
||||
// Search the index
|
||||
static async meiliSearch(q, params, populate) {
|
||||
const data = await index.search(q, params);
|
||||
|
||||
// Populate hits with content from mongodb
|
||||
if (populate) {
|
||||
// Find objects into mongodb matching `objectID` from Meili search
|
||||
const query = {};
|
||||
// query[primaryKey] = { $in: _.map(data.hits, primaryKey) };
|
||||
query[primaryKey] = _.map(data.hits, hit => cleanUpPrimaryKeyValue(hit[primaryKey]));
|
||||
// console.log('query', query);
|
||||
const hitsFromMongoose = await this.find(
|
||||
query,
|
||||
_.reduce(
|
||||
this.schema.obj,
|
||||
function (results, value, key) {
|
||||
return { ...results, [key]: 1 };
|
||||
},
|
||||
{ _id: 1 }
|
||||
),
|
||||
);
|
||||
|
||||
// Add additional data from mongodb into Meili search hits
|
||||
const populatedHits = data.hits.map(function (hit) {
|
||||
const query = {};
|
||||
query[primaryKey] = hit[primaryKey];
|
||||
const originalHit = _.find(hitsFromMongoose, query);
|
||||
|
||||
return {
|
||||
...(originalHit ? originalHit.toJSON() : {}),
|
||||
...hit
|
||||
};
|
||||
});
|
||||
data.hits = populatedHits;
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
// Push new document to Meili
|
||||
async addObjectToMeili() {
|
||||
const object = _.pick(this.toJSON(), attributesToIndex);
|
||||
// NOTE: MeiliSearch does not allow | in primary key, so we replace it with - for Bing convoIds
|
||||
// object.conversationId = object.conversationId.replace(/\|/g, '-');
|
||||
if (object.conversationId && object.conversationId.includes('|')) {
|
||||
object.conversationId = object.conversationId.replace(/\|/g, '--');
|
||||
}
|
||||
|
||||
try {
|
||||
// console.log('Adding document to Meili', object);
|
||||
await index.addDocuments([object]);
|
||||
} catch (error) {
|
||||
// console.log('Error adding document to Meili');
|
||||
// console.error(error);
|
||||
}
|
||||
|
||||
await this.collection.updateMany({ _id: this._id }, { $set: { _meiliIndex: true } });
|
||||
}
|
||||
|
||||
// Update an existing document in Meili
|
||||
async updateObjectToMeili() {
|
||||
const object = _.pick(this.toJSON(), attributesToIndex);
|
||||
await index.updateDocuments([object]);
|
||||
}
|
||||
|
||||
// Delete a document from Meili
|
||||
async deleteObjectFromMeili() {
|
||||
await index.deleteDocument(this._id);
|
||||
}
|
||||
|
||||
// * schema.post('save')
|
||||
postSaveHook() {
|
||||
if (this._meiliIndex) {
|
||||
this.updateObjectToMeili();
|
||||
} else {
|
||||
this.addObjectToMeili();
|
||||
}
|
||||
}
|
||||
|
||||
// * schema.post('update')
|
||||
postUpdateHook() {
|
||||
if (this._meiliIndex) {
|
||||
this.updateObjectToMeili();
|
||||
}
|
||||
}
|
||||
|
||||
// * schema.post('remove')
|
||||
postRemoveHook() {
|
||||
if (this._meiliIndex) {
|
||||
this.deleteObjectFromMeili();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return MeiliMongooseModel;
|
||||
};
|
||||
|
||||
module.exports = function mongoMeili(schema, options) {
|
||||
// Vaidate Options for mongoMeili
|
||||
validateOptions(options);
|
||||
|
||||
// Add meiliIndex to schema
|
||||
schema.add({
|
||||
_meiliIndex: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
select: false,
|
||||
default: false
|
||||
}
|
||||
});
|
||||
|
||||
const { host, apiKey, indexName, primaryKey } = options;
|
||||
|
||||
// Setup MeiliSearch Client
|
||||
const client = new MeiliSearch({ host, apiKey });
|
||||
|
||||
// Asynchronously create the index
|
||||
client.createIndex(indexName, { primaryKey });
|
||||
|
||||
// Setup the index to search for this schema
|
||||
const index = client.index(indexName);
|
||||
|
||||
const attributesToIndex = [
|
||||
..._.reduce(
|
||||
schema.obj,
|
||||
function (results, value, key) {
|
||||
return value.meiliIndex ? [...results, key] : results;
|
||||
// }, []), '_id'];
|
||||
},
|
||||
[]
|
||||
)
|
||||
];
|
||||
|
||||
schema.loadClass(createMeiliMongooseModel({ index, indexName, client, attributesToIndex }));
|
||||
|
||||
// Register hooks
|
||||
schema.post('save', function (doc) {
|
||||
doc.postSaveHook();
|
||||
});
|
||||
schema.post('update', function (doc) {
|
||||
doc.postUpdateHook();
|
||||
});
|
||||
schema.post('remove', function (doc) {
|
||||
doc.postRemoveHook();
|
||||
});
|
||||
schema.post('deleteMany', function () {
|
||||
// console.log('deleteMany hook', doc);
|
||||
if (Object.prototype.hasOwnProperty.call(schema.obj, 'messages')) {
|
||||
console.log('Syncing convos...');
|
||||
mongoose.model('Conversation').syncWithMeili();
|
||||
}
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(schema.obj, 'messageId')) {
|
||||
console.log('Syncing messages...');
|
||||
mongoose.model('Message').syncWithMeili();
|
||||
}
|
||||
});
|
||||
schema.post('findOneAndUpdate', function (doc) {
|
||||
doc.postSaveHook();
|
||||
});
|
||||
};
|
||||
71
api/models/schema/convoSchema.js
Normal file
71
api/models/schema/convoSchema.js
Normal file
@@ -0,0 +1,71 @@
|
||||
const mongoose = require('mongoose');
|
||||
const mongoMeili = require('../plugins/mongoMeili');
|
||||
const convoSchema = mongoose.Schema(
|
||||
{
|
||||
conversationId: {
|
||||
type: String,
|
||||
unique: true,
|
||||
required: true,
|
||||
index: true,
|
||||
meiliIndex: true
|
||||
},
|
||||
parentMessageId: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
default: 'New Chat',
|
||||
meiliIndex: true
|
||||
},
|
||||
jailbreakConversationId: {
|
||||
type: String,
|
||||
default: null
|
||||
},
|
||||
conversationSignature: {
|
||||
type: String,
|
||||
default: null
|
||||
},
|
||||
clientId: {
|
||||
type: String
|
||||
},
|
||||
invocationId: {
|
||||
type: String
|
||||
},
|
||||
toneStyle: {
|
||||
type: String,
|
||||
default: null
|
||||
},
|
||||
chatGptLabel: {
|
||||
type: String,
|
||||
default: null
|
||||
},
|
||||
promptPrefix: {
|
||||
type: String,
|
||||
default: null
|
||||
},
|
||||
model: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
user: {
|
||||
type: String
|
||||
},
|
||||
suggestions: [{ type: String }],
|
||||
messages: [{ type: mongoose.Schema.Types.ObjectId, ref: 'Message' }]
|
||||
},
|
||||
{ timestamps: true }
|
||||
);
|
||||
|
||||
if (process.env.MEILI_HOST && process.env.MEILI_MASTER_KEY) {
|
||||
convoSchema.plugin(mongoMeili, {
|
||||
host: process.env.MEILI_HOST,
|
||||
apiKey: process.env.MEILI_MASTER_KEY,
|
||||
indexName: 'convos', // Will get created automatically if it doesn't exist already
|
||||
primaryKey: 'conversationId'
|
||||
});
|
||||
}
|
||||
|
||||
const Conversation = mongoose.models.Conversation || mongoose.model('Conversation', convoSchema);
|
||||
|
||||
module.exports = Conversation;
|
||||
71
api/models/schema/messageSchema.js
Normal file
71
api/models/schema/messageSchema.js
Normal file
@@ -0,0 +1,71 @@
|
||||
const mongoose = require('mongoose');
|
||||
const mongoMeili = require('../plugins/mongoMeili');
|
||||
const messageSchema = mongoose.Schema(
|
||||
{
|
||||
messageId: {
|
||||
type: String,
|
||||
unique: true,
|
||||
required: true,
|
||||
index: true,
|
||||
meiliIndex: true
|
||||
},
|
||||
conversationId: {
|
||||
type: String,
|
||||
required: true,
|
||||
meiliIndex: true
|
||||
},
|
||||
conversationSignature: {
|
||||
type: String
|
||||
// required: true
|
||||
},
|
||||
clientId: {
|
||||
type: String
|
||||
},
|
||||
invocationId: {
|
||||
type: String
|
||||
},
|
||||
parentMessageId: {
|
||||
type: String
|
||||
// required: true
|
||||
},
|
||||
sender: {
|
||||
type: String,
|
||||
required: true,
|
||||
meiliIndex: true
|
||||
},
|
||||
text: {
|
||||
type: String,
|
||||
required: true,
|
||||
meiliIndex: true
|
||||
},
|
||||
isCreatedByUser: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
default: false
|
||||
},
|
||||
error: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
_meiliIndex: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
select: false,
|
||||
default: false
|
||||
}
|
||||
},
|
||||
{ timestamps: true }
|
||||
);
|
||||
|
||||
if (process.env.MEILI_HOST && process.env.MEILI_MASTER_KEY) {
|
||||
messageSchema.plugin(mongoMeili, {
|
||||
host: process.env.MEILI_HOST,
|
||||
apiKey: process.env.MEILI_MASTER_KEY,
|
||||
indexName: 'messages',
|
||||
primaryKey: 'messageId'
|
||||
});
|
||||
}
|
||||
|
||||
const Message = mongoose.models.Message || mongoose.model('Message', messageSchema);
|
||||
|
||||
module.exports = Message;
|
||||
2034
api/package-lock.json
generated
2034
api/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -21,14 +21,23 @@
|
||||
"dependencies": {
|
||||
"@keyv/mongo": "^2.1.8",
|
||||
"@vscode/vscode-languagedetection": "^1.0.22",
|
||||
"@waylaidwanderer/chatgpt-api": "^1.15.1",
|
||||
"@waylaidwanderer/chatgpt-api": "^1.32.8",
|
||||
"axios": "^1.3.4",
|
||||
"chatgpt-latest": "npm:@waylaidwanderer/chatgpt-api@^1.31.6",
|
||||
"cors": "^2.8.5",
|
||||
"crypto": "^1.0.1",
|
||||
"dotenv": "^16.0.3",
|
||||
"eslint": "^8.36.0",
|
||||
"express": "^4.18.2",
|
||||
"express-session": "^1.17.3",
|
||||
"html": "^1.0.0",
|
||||
"keyv": "^4.5.2",
|
||||
"keyv-file": "^0.2.0",
|
||||
"lodash": "^4.17.21",
|
||||
"meilisearch": "^0.31.1",
|
||||
"mongoose": "^6.9.0",
|
||||
"openai": "^3.1.0"
|
||||
"openai": "^3.1.0",
|
||||
"sanitize": "^2.1.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nodemon": "^2.0.20",
|
||||
|
||||
33
api/server/controllers/errorController.js
Normal file
33
api/server/controllers/errorController.js
Normal file
@@ -0,0 +1,33 @@
|
||||
//handle duplicates
|
||||
const handleDuplicateKeyError = (err, res) => {
|
||||
const field = Object.keys(err.keyValue);
|
||||
const code = 409;
|
||||
const error = `An document with that ${field} already exists.`;
|
||||
console.log('congrats you hit the duped keys error');
|
||||
res.status(code).send({ messages: error, fields: field });
|
||||
};
|
||||
|
||||
//handle validation errors
|
||||
const handleValidationError = (err, res) => {
|
||||
console.log('congrats you hit the validation middleware');
|
||||
let errors = Object.values(err.errors).map(el => el.message);
|
||||
let fields = Object.values(err.errors).map(el => el.path);
|
||||
let code = 400;
|
||||
if (errors.length > 1) {
|
||||
const formattedErrors = errors.join(' ');
|
||||
res.status(code).send({ messages: formattedErrors, fields: fields });
|
||||
} else {
|
||||
res.status(code).send({ messages: errors, fields: fields });
|
||||
}
|
||||
};
|
||||
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
module.exports = (err, req, res, next) => {
|
||||
try {
|
||||
console.log('congrats you hit the error middleware');
|
||||
if (err.name === 'ValidationError') return (err = handleValidationError(err, res));
|
||||
if (err.code && err.code == 11000) return (err = handleDuplicateKeyError(err, res));
|
||||
} catch (err) {
|
||||
res.status(500).send('An unknown error occurred.');
|
||||
}
|
||||
};
|
||||
@@ -1,28 +1,96 @@
|
||||
const express = require('express');
|
||||
const dbConnect = require('../models/dbConnect');
|
||||
const session = require('express-session');
|
||||
const connectDb = require('../lib/db/connectDb');
|
||||
const migrateDb = require('../lib/db/migrateDb');
|
||||
const indexSync = require('../lib/db/indexSync');
|
||||
const path = require('path');
|
||||
const cors = require('cors');
|
||||
const routes = require('./routes');
|
||||
const app = express();
|
||||
const errorController = require('./controllers/errorController');
|
||||
|
||||
const port = process.env.PORT || 3080;
|
||||
const host = process.env.HOST || 'localhost';
|
||||
const userSystemEnabled = process.env.ENABLE_USER_SYSTEM || false;
|
||||
const projectPath = path.join(__dirname, '..', '..', 'client');
|
||||
dbConnect().then(() => console.log('Connected to MongoDB'));
|
||||
|
||||
app.use(cors());
|
||||
app.use(express.json());
|
||||
app.use(express.static(path.join(projectPath, 'public')));
|
||||
(async () => {
|
||||
await connectDb();
|
||||
console.log('Connected to MongoDB');
|
||||
await migrateDb();
|
||||
await indexSync();
|
||||
|
||||
app.get('/', function (req, res) {
|
||||
console.log(path.join(projectPath, 'public', 'index.html'));
|
||||
res.sendFile(path.join(projectPath, 'public', 'index.html'));
|
||||
const app = express();
|
||||
app.use(errorController);
|
||||
app.use(cors());
|
||||
app.use(express.json());
|
||||
app.use(express.static(path.join(projectPath, 'public')));
|
||||
app.set('trust proxy', 1); // trust first proxy
|
||||
app.use(
|
||||
session({
|
||||
secret: 'chatgpt-clone-random-secrect',
|
||||
resave: false,
|
||||
saveUninitialized: true
|
||||
})
|
||||
);
|
||||
|
||||
/* chore: potential redirect error here, can only comment out this block;
|
||||
comment back in if using auth routes i guess */
|
||||
// app.get('/', routes.authenticatedOrRedirect, function (req, res) {
|
||||
// console.log(path.join(projectPath, 'public', 'index.html'));
|
||||
// res.sendFile(path.join(projectPath, 'public', 'index.html'));
|
||||
// });
|
||||
|
||||
app.get('/api/me', function (req, res) {
|
||||
if (userSystemEnabled) {
|
||||
const user = req?.session?.user;
|
||||
|
||||
if (user) res.send(JSON.stringify({ username: user?.username, display: user?.display }));
|
||||
else res.send(JSON.stringify(null));
|
||||
} else {
|
||||
res.send(JSON.stringify({ username: 'anonymous_user', display: 'Anonymous User' }));
|
||||
}
|
||||
});
|
||||
|
||||
app.use('/api/search', routes.authenticatedOr401, routes.search);
|
||||
app.use('/api/ask', routes.authenticatedOr401, routes.ask);
|
||||
app.use('/api/messages', routes.authenticatedOr401, routes.messages);
|
||||
app.use('/api/convos', routes.authenticatedOr401, routes.convos);
|
||||
app.use('/api/customGpts', routes.authenticatedOr401, routes.customGpts);
|
||||
app.use('/api/prompts', routes.authenticatedOr401, routes.prompts);
|
||||
app.use('/auth', routes.auth);
|
||||
|
||||
app.get('/api/models', function (req, res) {
|
||||
const hasOpenAI = !!process.env.OPENAI_KEY;
|
||||
const hasChatGpt = !!process.env.CHATGPT_TOKEN;
|
||||
const hasBing = !!process.env.BING_TOKEN;
|
||||
|
||||
res.send(JSON.stringify({ hasOpenAI, hasChatGpt, hasBing }));
|
||||
});
|
||||
|
||||
app.listen(port, host, () => {
|
||||
if (host == '0.0.0.0')
|
||||
console.log(
|
||||
`Server listening on all interface at port ${port}. Use http://localhost:${port} to access it`
|
||||
);
|
||||
else
|
||||
console.log(
|
||||
`Server listening at http://${host == '0.0.0.0' ? 'localhost' : host}:${port}`
|
||||
);
|
||||
});
|
||||
})();
|
||||
|
||||
let messageCount = 0;
|
||||
process.on('uncaughtException', (err) => {
|
||||
if (!err.message.includes('fetch failed')) {
|
||||
console.error('There was an uncaught error:', err.message);
|
||||
}
|
||||
|
||||
if (err.message.includes('fetch failed')) {
|
||||
if (messageCount === 0) {
|
||||
console.error('Meilisearch error, search will be disabled');
|
||||
messageCount++;
|
||||
}
|
||||
} else {
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
app.use('/api/ask', routes.ask);
|
||||
app.use('/api/messages', routes.messages);
|
||||
app.use('/api/convos', routes.convos);
|
||||
app.use('/api/customGpts', routes.customGpts);
|
||||
app.use('/api/prompts', routes.prompts);
|
||||
|
||||
app.listen(port, () => {
|
||||
console.log(`Server listening at http://localhost:${port}`);
|
||||
});
|
||||
@@ -2,36 +2,79 @@ const express = require('express');
|
||||
const crypto = require('crypto');
|
||||
const router = express.Router();
|
||||
const askBing = require('./askBing');
|
||||
const {
|
||||
titleConvo,
|
||||
askClient,
|
||||
browserClient,
|
||||
customClient,
|
||||
detectCode
|
||||
} = require('../../app/');
|
||||
const { getConvo, saveMessage, deleteMessages, saveConvo } = require('../../models');
|
||||
const { handleError, sendMessage } = require('./handlers');
|
||||
const askSydney = require('./askSydney');
|
||||
const { titleConvo, askClient, browserClient, customClient } = require('../../app/');
|
||||
const { getConvo, saveMessage, getConvoTitle, saveConvo } = require('../../models');
|
||||
const { handleError, sendMessage, createOnProgress, handleText } = require('./handlers');
|
||||
const { getMessages } = require('../../models/Message');
|
||||
|
||||
router.use('/bing', askBing);
|
||||
router.use('/sydney', askSydney);
|
||||
|
||||
router.post('/', async (req, res) => {
|
||||
let { model, text, parentMessageId, conversationId, chatGptLabel, promptPrefix } = req.body;
|
||||
if (!text.trim().includes(' ') && text.length < 5) {
|
||||
return handleError(res, 'Prompt empty or too short');
|
||||
let { model, text, overrideParentMessageId=null, parentMessageId, conversationId: oldConversationId, ...convo } = req.body;
|
||||
if (text.length === 0) {
|
||||
return handleError(res, { text: 'Prompt empty or too short' });
|
||||
}
|
||||
|
||||
console.log('model:', model, 'oldConvoId:', oldConversationId);
|
||||
const conversationId = oldConversationId || crypto.randomUUID();
|
||||
console.log('conversationId after old:', conversationId);
|
||||
|
||||
const userMessageId = crypto.randomUUID();
|
||||
let userMessage = { id: userMessageId, sender: 'User', text };
|
||||
const userParentMessageId = parentMessageId || '00000000-0000-0000-0000-000000000000';
|
||||
let userMessage = {
|
||||
messageId: userMessageId,
|
||||
sender: 'User',
|
||||
text,
|
||||
parentMessageId: userParentMessageId,
|
||||
conversationId,
|
||||
isCreatedByUser: true
|
||||
};
|
||||
|
||||
console.log('ask log', {
|
||||
model,
|
||||
...userMessage,
|
||||
parentMessageId,
|
||||
conversationId,
|
||||
chatGptLabel,
|
||||
promptPrefix
|
||||
...convo
|
||||
});
|
||||
|
||||
// Chore: This creates a loose a stranded initial message for chatgptBrowser
|
||||
|
||||
if (!overrideParentMessageId) {
|
||||
await saveMessage(userMessage);
|
||||
}
|
||||
|
||||
if (!overrideParentMessageId && model !== 'chatgptBrowser') {
|
||||
await saveConvo(req?.session?.user?.username, { ...userMessage, model, ...convo });
|
||||
}
|
||||
|
||||
return await ask({
|
||||
userMessage,
|
||||
model,
|
||||
convo,
|
||||
preSendRequest: true,
|
||||
overrideParentMessageId,
|
||||
req,
|
||||
res
|
||||
});
|
||||
});
|
||||
|
||||
const ask = async ({
|
||||
userMessage,
|
||||
overrideParentMessageId = null,
|
||||
model,
|
||||
convo,
|
||||
preSendRequest = true,
|
||||
req,
|
||||
res
|
||||
}) => {
|
||||
let {
|
||||
text,
|
||||
parentMessageId: userParentMessageId,
|
||||
conversationId,
|
||||
messageId: userMessageId
|
||||
} = userMessage;
|
||||
|
||||
let client;
|
||||
|
||||
if (model === 'chatgpt') {
|
||||
@@ -42,15 +85,6 @@ router.post('/', async (req, res) => {
|
||||
client = browserClient;
|
||||
}
|
||||
|
||||
if (model === 'chatgptCustom' && !chatGptLabel && conversationId) {
|
||||
const convo = await getConvo({ conversationId });
|
||||
if (convo) {
|
||||
console.log('found convo for custom gpt', { convo })
|
||||
chatGptLabel = convo.chatGptLabel;
|
||||
promptPrefix = convo.promptPrefix;
|
||||
}
|
||||
}
|
||||
|
||||
res.writeHead(200, {
|
||||
Connection: 'keep-alive',
|
||||
'Content-Type': 'text/event-stream',
|
||||
@@ -59,54 +93,40 @@ router.post('/', async (req, res) => {
|
||||
'X-Accel-Buffering': 'no'
|
||||
});
|
||||
|
||||
if (preSendRequest) sendMessage(res, { message: userMessage, created: true });
|
||||
|
||||
try {
|
||||
let i = 0;
|
||||
let tokens = '';
|
||||
const progressCallback = async (partial) => {
|
||||
if (i === 0 && typeof partial === 'object') {
|
||||
userMessage.parentMessageId = parentMessageId ? parentMessageId : partial.id;
|
||||
userMessage.conversationId = conversationId ? conversationId : partial.conversationId;
|
||||
await saveMessage(userMessage);
|
||||
sendMessage(res, { ...partial, initial: true });
|
||||
i++;
|
||||
}
|
||||
const progressCallback = createOnProgress();
|
||||
|
||||
if (typeof partial === 'object') {
|
||||
sendMessage(res, { ...partial, message: true });
|
||||
} else {
|
||||
tokens += partial === text ? '' : partial;
|
||||
if (tokens.includes('[DONE]')) {
|
||||
tokens = tokens.replace('[DONE]', '');
|
||||
}
|
||||
|
||||
// tokens = await detectCode(tokens);
|
||||
sendMessage(res, { text: tokens, message: true, initial: i === 0 ? true : false });
|
||||
i++;
|
||||
}
|
||||
};
|
||||
const abortController = new AbortController();
|
||||
res.on('close', () => {
|
||||
console.log('The client has disconnected.');
|
||||
// 执行其他操作
|
||||
abortController.abort();
|
||||
})
|
||||
|
||||
let gptResponse = await client({
|
||||
text,
|
||||
progressCallback,
|
||||
onProgress: progressCallback.call(null, model, { res, text }),
|
||||
convo: {
|
||||
parentMessageId,
|
||||
conversationId
|
||||
parentMessageId: userParentMessageId,
|
||||
conversationId,
|
||||
...convo
|
||||
},
|
||||
chatGptLabel,
|
||||
promptPrefix
|
||||
...convo,
|
||||
abortController
|
||||
});
|
||||
|
||||
console.log('CLIENT RESPONSE', gptResponse);
|
||||
gptResponse.text = gptResponse.response;
|
||||
|
||||
if (!gptResponse.parentMessageId) {
|
||||
gptResponse.text = gptResponse.response;
|
||||
gptResponse.id = gptResponse.messageId;
|
||||
gptResponse.parentMessageId = gptResponse.messageId;
|
||||
userMessage.parentMessageId = parentMessageId ? parentMessageId : gptResponse.messageId;
|
||||
userMessage.conversationId = conversationId
|
||||
? conversationId
|
||||
: gptResponse.conversationId;
|
||||
await saveMessage(userMessage);
|
||||
// gptResponse.id = gptResponse.messageId;
|
||||
gptResponse.parentMessageId = overrideParentMessageId || userMessageId;
|
||||
// userMessage.conversationId = conversationId
|
||||
// ? conversationId
|
||||
// : gptResponse.conversationId;
|
||||
// await saveMessage(userMessage);
|
||||
delete gptResponse.response;
|
||||
}
|
||||
|
||||
@@ -115,37 +135,75 @@ router.post('/', async (req, res) => {
|
||||
gptResponse.text.toLowerCase().includes('no response') ||
|
||||
gptResponse.text.toLowerCase().includes('no answer')
|
||||
) {
|
||||
return handleError(res, 'Prompt empty or too short');
|
||||
}
|
||||
|
||||
if (!parentMessageId) {
|
||||
gptResponse.title = await titleConvo({
|
||||
model,
|
||||
message: text,
|
||||
response: JSON.stringify(gptResponse.text)
|
||||
await saveMessage({
|
||||
messageId: crypto.randomUUID(),
|
||||
sender: model,
|
||||
conversationId,
|
||||
parentMessageId: overrideParentMessageId || userMessageId,
|
||||
error: true,
|
||||
text: 'Prompt empty or too short'
|
||||
});
|
||||
}
|
||||
gptResponse.sender = model === 'chatgptCustom' ? chatGptLabel : model;
|
||||
gptResponse.final = true;
|
||||
gptResponse.text = await detectCode(gptResponse.text);
|
||||
|
||||
if (chatGptLabel?.length > 0 && model === 'chatgptCustom') {
|
||||
gptResponse.chatGptLabel = chatGptLabel;
|
||||
return handleError(res, { text: 'Prompt empty or too short' });
|
||||
}
|
||||
|
||||
if (promptPrefix?.length > 0 && model === 'chatgptCustom') {
|
||||
gptResponse.promptPrefix = promptPrefix;
|
||||
gptResponse.sender = model === 'chatgptCustom' ? convo.chatGptLabel : model;
|
||||
gptResponse.model = model;
|
||||
// gptResponse.final = true;
|
||||
gptResponse.text = await handleText(gptResponse);
|
||||
|
||||
|
||||
if (convo.chatGptLabel?.length > 0 && model === 'chatgptCustom') {
|
||||
gptResponse.chatGptLabel = convo.chatGptLabel;
|
||||
}
|
||||
|
||||
if (convo.promptPrefix?.length > 0 && model === 'chatgptCustom') {
|
||||
gptResponse.promptPrefix = convo.promptPrefix;
|
||||
}
|
||||
|
||||
// override the parentMessageId, for the regeneration.
|
||||
gptResponse.parentMessageId = overrideParentMessageId || userMessageId;
|
||||
|
||||
/* this is a hacky solution to get the browserClient working right, will refactor later */
|
||||
if (model === 'chatgptBrowser' && userParentMessageId.startsWith('000')) {
|
||||
await saveMessage({ ...userMessage, conversationId: gptResponse.conversationId });
|
||||
}
|
||||
|
||||
await saveMessage(gptResponse);
|
||||
await saveConvo(gptResponse);
|
||||
sendMessage(res, gptResponse);
|
||||
await saveConvo(req?.session?.user?.username, gptResponse);
|
||||
sendMessage(res, {
|
||||
title: await getConvoTitle(req?.session?.user?.username, conversationId),
|
||||
final: true,
|
||||
requestMessage: userMessage,
|
||||
responseMessage: gptResponse
|
||||
});
|
||||
res.end();
|
||||
|
||||
if (userParentMessageId == '00000000-0000-0000-0000-000000000000') {
|
||||
const title = await titleConvo({ model, text, response: gptResponse });
|
||||
|
||||
await saveConvo(
|
||||
req?.session?.user?.username,
|
||||
{
|
||||
/* again, for sake of browser client, will soon refactor */
|
||||
conversationId: model === 'chatgptBrowser' ? gptResponse.conversationId : conversationId,
|
||||
title
|
||||
}
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
await deleteMessages({ id: userMessageId });
|
||||
handleError(res, error.message);
|
||||
// await deleteMessages({ messageId: userMessageId });
|
||||
const errorMessage = {
|
||||
messageId: crypto.randomUUID(),
|
||||
sender: model,
|
||||
conversationId,
|
||||
parentMessageId: overrideParentMessageId || userMessageId,
|
||||
error: true,
|
||||
text: error.message
|
||||
};
|
||||
await saveMessage(errorMessage);
|
||||
handleError(res, errorMessage);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -2,19 +2,75 @@ const express = require('express');
|
||||
const crypto = require('crypto');
|
||||
const router = express.Router();
|
||||
const { titleConvo, askBing } = require('../../app/');
|
||||
const { saveMessage, deleteMessages, saveConvo } = require('../../models');
|
||||
const { handleError, sendMessage } = require('./handlers');
|
||||
const { saveBingMessage, getConvoTitle, saveConvo } = require('../../models');
|
||||
const { handleError, sendMessage, createOnProgress, handleText } = require('./handlers');
|
||||
|
||||
router.post('/', async (req, res) => {
|
||||
const { model, text, ...convo } = req.body;
|
||||
if (!text.trim().includes(' ') && text.length < 5) {
|
||||
return handleError(res, 'Prompt empty or too short');
|
||||
const {
|
||||
model,
|
||||
text,
|
||||
overrideParentMessageId=null,
|
||||
parentMessageId,
|
||||
conversationId: oldConversationId,
|
||||
...convo
|
||||
} = req.body;
|
||||
if (text.length === 0) {
|
||||
return handleError(res, { text: 'Prompt empty or too short' });
|
||||
}
|
||||
|
||||
const userMessageId = crypto.randomUUID();
|
||||
let userMessage = { id: userMessageId, sender: 'User', text };
|
||||
const conversationId = oldConversationId || crypto.randomUUID();
|
||||
const isNewConversation = !oldConversationId;
|
||||
|
||||
console.log('ask log', { model, ...userMessage, ...convo });
|
||||
const userMessageId = convo.messageId;
|
||||
const userParentMessageId = parentMessageId || '00000000-0000-0000-0000-000000000000';
|
||||
let userMessage = {
|
||||
messageId: userMessageId,
|
||||
sender: 'User',
|
||||
text,
|
||||
parentMessageId: userParentMessageId,
|
||||
conversationId,
|
||||
isCreatedByUser: true
|
||||
};
|
||||
|
||||
console.log('ask log', {
|
||||
model,
|
||||
...convo,
|
||||
...userMessage
|
||||
});
|
||||
|
||||
if (!overrideParentMessageId) {
|
||||
await saveBingMessage(userMessage);
|
||||
await saveConvo(req?.session?.user?.username, { model, ...convo, ...userMessage });
|
||||
}
|
||||
|
||||
return await ask({
|
||||
isNewConversation,
|
||||
userMessage,
|
||||
model,
|
||||
convo,
|
||||
preSendRequest: true,
|
||||
overrideParentMessageId,
|
||||
req,
|
||||
res
|
||||
});
|
||||
});
|
||||
|
||||
const ask = async ({
|
||||
isNewConversation,
|
||||
overrideParentMessageId = null,
|
||||
userMessage,
|
||||
model,
|
||||
convo,
|
||||
preSendRequest = true,
|
||||
req,
|
||||
res
|
||||
}) => {
|
||||
let {
|
||||
text,
|
||||
parentMessageId: userParentMessageId,
|
||||
conversationId,
|
||||
messageId: userMessageId
|
||||
} = userMessage;
|
||||
|
||||
res.writeHead(200, {
|
||||
Connection: 'keep-alive',
|
||||
@@ -24,49 +80,111 @@ router.post('/', async (req, res) => {
|
||||
'X-Accel-Buffering': 'no'
|
||||
});
|
||||
|
||||
if (preSendRequest) sendMessage(res, { message: userMessage, created: true });
|
||||
|
||||
try {
|
||||
let tokens = '';
|
||||
const progressCallback = async (partial) => {
|
||||
tokens += partial === text ? '' : partial;
|
||||
// tokens = appendCode(tokens);
|
||||
sendMessage(res, { text: tokens, message: true });
|
||||
};
|
||||
const progressCallback = createOnProgress();
|
||||
|
||||
const abortController = new AbortController();
|
||||
res.on('close', () => {
|
||||
console.log('The client has disconnected.');
|
||||
// 执行其他操作
|
||||
abortController.abort();
|
||||
})
|
||||
|
||||
let response = await askBing({
|
||||
text,
|
||||
progressCallback,
|
||||
convo
|
||||
onProgress: progressCallback.call(null, model, {
|
||||
res,
|
||||
text,
|
||||
parentMessageId: overrideParentMessageId || userMessageId
|
||||
}),
|
||||
convo: {
|
||||
...convo,
|
||||
parentMessageId: userParentMessageId,
|
||||
conversationId
|
||||
},
|
||||
abortController
|
||||
});
|
||||
|
||||
console.log('CLIENT RESPONSE');
|
||||
console.dir(response, { depth: null });
|
||||
console.log('BING RESPONSE', response);
|
||||
// console.dir(response, { depth: null });
|
||||
|
||||
userMessage.conversationSignature =
|
||||
convo.conversationSignature || response.conversationSignature;
|
||||
userMessage.conversationId = convo.conversationId || response.conversationId;
|
||||
userMessage.conversationId = response.conversationId || conversationId;
|
||||
userMessage.invocationId = response.invocationId;
|
||||
await saveMessage(userMessage);
|
||||
userMessage.messageId = response.details.requestId || userMessageId;
|
||||
if (!overrideParentMessageId)
|
||||
await saveBingMessage({ oldMessageId: userMessageId, ...userMessage });
|
||||
|
||||
if (!convo.conversationSignature) {
|
||||
response.title = await titleConvo(text, response.response, model);
|
||||
}
|
||||
// Bing API will not use our conversationId at the first time,
|
||||
// so change the placeholder conversationId to the real one.
|
||||
// Attition: the api will also create new conversationId while using invalid userMessage.parentMessageId,
|
||||
// but in this situation, don't change the conversationId, but create new convo.
|
||||
if (conversationId != userMessage.conversationId && isNewConversation)
|
||||
await saveConvo(
|
||||
req?.session?.user?.username,
|
||||
{
|
||||
conversationId: conversationId,
|
||||
newConversationId: userMessage.conversationId
|
||||
}
|
||||
);
|
||||
conversationId = userMessage.conversationId;
|
||||
|
||||
response.text = response.response;
|
||||
response.id = response.details.messageId;
|
||||
response.text = response.response || response.details.spokenText || '**Bing refused to answer.**';
|
||||
// delete response.response;
|
||||
// response.id = response.details.messageId;
|
||||
response.suggestions =
|
||||
response.details.suggestedResponses &&
|
||||
response.details.suggestedResponses.map((s) => s.text);
|
||||
response.sender = model;
|
||||
response.final = true;
|
||||
await saveMessage(response);
|
||||
await saveConvo(response);
|
||||
sendMessage(res, response);
|
||||
// response.final = true;
|
||||
|
||||
response.messageId = response.details.messageId;
|
||||
// override the parentMessageId, for the regeneration.
|
||||
response.parentMessageId =
|
||||
overrideParentMessageId || response.details.requestId || userMessageId;
|
||||
|
||||
response.text = await handleText(response, true);
|
||||
await saveBingMessage(response);
|
||||
await saveConvo(req?.session?.user?.username, { model, chatGptLabel: null, promptPrefix: null, ...convo, ...response });
|
||||
|
||||
sendMessage(res, {
|
||||
title: await getConvoTitle(req?.session?.user?.username, conversationId),
|
||||
final: true,
|
||||
requestMessage: userMessage,
|
||||
responseMessage: response
|
||||
});
|
||||
res.end();
|
||||
|
||||
if (userParentMessageId == '00000000-0000-0000-0000-000000000000') {
|
||||
const title = await titleConvo({ model, text, response });
|
||||
|
||||
await saveConvo(
|
||||
req?.session?.user?.username,
|
||||
{
|
||||
...convo,
|
||||
...response,
|
||||
conversationId,
|
||||
title
|
||||
}
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
await deleteMessages({ id: userMessageId });
|
||||
handleError(res, error.message);
|
||||
// await deleteMessages({ messageId: userMessageId });
|
||||
const errorMessage = {
|
||||
messageId: crypto.randomUUID(),
|
||||
sender: model,
|
||||
conversationId,
|
||||
parentMessageId: overrideParentMessageId || userMessageId,
|
||||
error: true,
|
||||
text: error.message
|
||||
};
|
||||
await saveBingMessage(errorMessage);
|
||||
handleError(res, errorMessage);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
module.exports = router;
|
||||
|
||||
200
api/server/routes/askSydney.js
Normal file
200
api/server/routes/askSydney.js
Normal file
@@ -0,0 +1,200 @@
|
||||
const express = require('express');
|
||||
const crypto = require('crypto');
|
||||
const router = express.Router();
|
||||
const { titleConvo, askSydney } = require('../../app/');
|
||||
const { saveBingMessage, saveConvo, getConvoTitle } = require('../../models');
|
||||
const { handleError, sendMessage, createOnProgress, handleText } = require('./handlers');
|
||||
|
||||
router.post('/', async (req, res) => {
|
||||
const {
|
||||
model,
|
||||
text,
|
||||
overrideParentMessageId=null,
|
||||
parentMessageId,
|
||||
conversationId: oldConversationId,
|
||||
...convo
|
||||
} = req.body;
|
||||
if (text.length === 0) {
|
||||
return handleError(res, { text: 'Prompt empty or too short' });
|
||||
}
|
||||
|
||||
const conversationId = oldConversationId || crypto.randomUUID();
|
||||
const isNewConversation = !oldConversationId;
|
||||
|
||||
const userMessageId = convo.messageId;
|
||||
const userParentMessageId = parentMessageId || '00000000-0000-0000-0000-000000000000';
|
||||
let userMessage = {
|
||||
messageId: userMessageId,
|
||||
sender: 'User',
|
||||
text,
|
||||
parentMessageId: userParentMessageId,
|
||||
conversationId,
|
||||
isCreatedByUser: true
|
||||
};
|
||||
|
||||
console.log('ask log', {
|
||||
model,
|
||||
...convo,
|
||||
...userMessage
|
||||
});
|
||||
|
||||
if (!overrideParentMessageId) {
|
||||
await saveBingMessage(userMessage);
|
||||
await saveConvo(req?.session?.user?.username, { model, ...convo, ...userMessage });
|
||||
}
|
||||
|
||||
return await ask({
|
||||
isNewConversation,
|
||||
userMessage,
|
||||
model,
|
||||
convo,
|
||||
preSendRequest: true,
|
||||
overrideParentMessageId,
|
||||
req,
|
||||
res
|
||||
});
|
||||
});
|
||||
|
||||
const ask = async ({
|
||||
isNewConversation,
|
||||
overrideParentMessageId = null,
|
||||
userMessage,
|
||||
model,
|
||||
convo,
|
||||
preSendRequest = true,
|
||||
req,
|
||||
res
|
||||
}) => {
|
||||
let {
|
||||
text,
|
||||
parentMessageId: userParentMessageId,
|
||||
conversationId,
|
||||
messageId: userMessageId
|
||||
} = userMessage;
|
||||
|
||||
res.writeHead(200, {
|
||||
Connection: 'keep-alive',
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache, no-transform',
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'X-Accel-Buffering': 'no'
|
||||
});
|
||||
|
||||
if (preSendRequest) sendMessage(res, { message: userMessage, created: true });
|
||||
|
||||
try {
|
||||
const progressCallback = createOnProgress();
|
||||
|
||||
const abortController = new AbortController();
|
||||
res.on('close', () => {
|
||||
console.log('The client has disconnected.');
|
||||
// 执行其他操作
|
||||
abortController.abort();
|
||||
})
|
||||
|
||||
let response = await askSydney({
|
||||
text,
|
||||
onProgress: progressCallback.call(null, model, {
|
||||
res,
|
||||
text,
|
||||
parentMessageId: overrideParentMessageId || userMessageId
|
||||
}),
|
||||
convo: {
|
||||
...convo,
|
||||
parentMessageId: userParentMessageId,
|
||||
conversationId
|
||||
},
|
||||
abortController
|
||||
});
|
||||
|
||||
console.log('SYDNEY RESPONSE', response);
|
||||
// console.dir(response, { depth: null });
|
||||
|
||||
userMessage.conversationSignature =
|
||||
convo.conversationSignature || response.conversationSignature;
|
||||
userMessage.conversationId = response.conversationId || conversationId;
|
||||
userMessage.invocationId = response.invocationId;
|
||||
// Unlike gpt and bing, Sydney will never accept our given userMessage.messageId, it will generate its own one.
|
||||
userMessage.messageId = response.parentMessageId || userMessageId;
|
||||
|
||||
// Save sydney response
|
||||
// response.id = response.messageId;
|
||||
response.invocationId = convo.invocationId ? convo.invocationId + 1 : 1;
|
||||
response.conversationId = conversationId ? conversationId : crypto.randomUUID();
|
||||
response.conversationSignature = convo.conversationSignature
|
||||
? convo.conversationSignature
|
||||
: crypto.randomUUID();
|
||||
response.text = response.response || response.details.spokenText || '**Bing refused to answer.**';
|
||||
// delete response.response;
|
||||
response.suggestions =
|
||||
response.details.suggestedResponses &&
|
||||
response.details.suggestedResponses.map((s) => s.text);
|
||||
response.sender = model;
|
||||
// response.final = true;
|
||||
|
||||
// override the parentMessageId, for the regeneration.
|
||||
response.parentMessageId =
|
||||
overrideParentMessageId || response.parentMessageId || userMessageId;
|
||||
|
||||
// Save user message
|
||||
userMessage.conversationId = response.conversationId || conversationId;
|
||||
if (!overrideParentMessageId)
|
||||
await saveBingMessage({ oldMessageId: userMessageId, ...userMessage });
|
||||
|
||||
// Bing API will not use our conversationId at the first time,
|
||||
// so change the placeholder conversationId to the real one.
|
||||
// Attition: the api will also create new conversationId while using invalid userMessage.parentMessageId,
|
||||
// but in this situation, don't change the conversationId, but create new convo.
|
||||
if (conversationId != userMessage.conversationId && isNewConversation)
|
||||
await saveConvo(
|
||||
req?.session?.user?.username,
|
||||
{
|
||||
conversationId: conversationId,
|
||||
newConversationId: userMessage.conversationId
|
||||
}
|
||||
);
|
||||
conversationId = userMessage.conversationId;
|
||||
|
||||
response.text = await handleText(response, true);
|
||||
// Save sydney response & convo, then send
|
||||
await saveBingMessage(response);
|
||||
await saveConvo(req?.session?.user?.username, { model, chatGptLabel: null, promptPrefix: null, ...convo, ...response });
|
||||
|
||||
sendMessage(res, {
|
||||
title: await getConvoTitle(req?.session?.user?.username, conversationId),
|
||||
final: true,
|
||||
requestMessage: userMessage,
|
||||
responseMessage: response
|
||||
});
|
||||
res.end();
|
||||
|
||||
if (userParentMessageId == '00000000-0000-0000-0000-000000000000') {
|
||||
const title = await titleConvo({ model, text, response });
|
||||
|
||||
await saveConvo(
|
||||
req?.session?.user?.username,
|
||||
{
|
||||
...convo,
|
||||
...response,
|
||||
conversationId,
|
||||
title
|
||||
}
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
// await deleteMessages({ messageId: userMessageId });
|
||||
const errorMessage = {
|
||||
messageId: crypto.randomUUID(),
|
||||
sender: model,
|
||||
conversationId,
|
||||
parentMessageId: overrideParentMessageId || userMessageId,
|
||||
error: true,
|
||||
text: error.message
|
||||
};
|
||||
await saveBingMessage(errorMessage);
|
||||
handleError(res, errorMessage);
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = router;
|
||||
57
api/server/routes/auth.js
Normal file
57
api/server/routes/auth.js
Normal file
@@ -0,0 +1,57 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const authYourLogin = require('./authYourLogin');
|
||||
const userSystemEnabled = !!process.env.ENABLE_USER_SYSTEM || false;
|
||||
|
||||
router.get('/login', function (req, res) {
|
||||
if (userSystemEnabled) {
|
||||
res.redirect('/auth/your_login_page');
|
||||
} else {
|
||||
res.redirect('/');
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/logout', function (req, res) {
|
||||
// clear the session
|
||||
req.session.user = null;
|
||||
|
||||
req.session.save(function () {
|
||||
if (userSystemEnabled) {
|
||||
res.redirect('/auth/your_login_page/logout');
|
||||
} else {
|
||||
res.redirect('/');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const authenticatedOr401 = (req, res, next) => {
|
||||
if (userSystemEnabled) {
|
||||
const user = req?.session?.user;
|
||||
|
||||
if (user) {
|
||||
next();
|
||||
} else {
|
||||
res.status(401).end();
|
||||
}
|
||||
} else {
|
||||
next();
|
||||
}
|
||||
};
|
||||
|
||||
const authenticatedOrRedirect = (req, res, next) => {
|
||||
if (userSystemEnabled) {
|
||||
const user = req?.session?.user;
|
||||
|
||||
if (user) {
|
||||
next();
|
||||
} else {
|
||||
res.redirect('/auth/login').end();
|
||||
}
|
||||
} else next();
|
||||
};
|
||||
|
||||
if (userSystemEnabled) {
|
||||
router.use('/your_login_page', authYourLogin);
|
||||
}
|
||||
|
||||
module.exports = { router, authenticatedOr401, authenticatedOrRedirect };
|
||||
44
api/server/routes/authYourLogin.js
Normal file
44
api/server/routes/authYourLogin.js
Normal file
@@ -0,0 +1,44 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
|
||||
// WARNING!
|
||||
// THIS IS NOT A READY TO USE USER SYSTEM
|
||||
// PLEASE IMPLEMENT YOUR OWN USER SYSTEM
|
||||
|
||||
const userSystemEnabled = process.env.ENABLE_USER_SYSTEM || false;
|
||||
|
||||
// Logout
|
||||
router.get('/logout', (req, res) => {
|
||||
// Do anything you want
|
||||
console.warn('logout not implemented!');
|
||||
|
||||
// finish
|
||||
res.redirect('/');
|
||||
});
|
||||
|
||||
// Login
|
||||
router.get('/', async (req, res) => {
|
||||
// Do anything you want
|
||||
console.warn('login not implemented! Automatic passed as sample user');
|
||||
|
||||
// save the user info into session
|
||||
// username will be used in db
|
||||
// display will be used in UI
|
||||
if (userSystemEnabled) {
|
||||
req.session.user = {
|
||||
username: null, // was 'sample_user', but would break previous relationship with previous conversations before v0.1.0
|
||||
display: 'Sample User'
|
||||
};
|
||||
}
|
||||
|
||||
req.session.save(function (error) {
|
||||
if (error) {
|
||||
console.log(error);
|
||||
res.send(`<h1>Login Failed. An error occurred. Please see the server logs for details.</h1>`);
|
||||
} else {
|
||||
res.redirect('/');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -1,10 +1,42 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { getConvos, deleteConvos, updateConvo } = require('../../models/Conversation');
|
||||
const { titleConvo } = require('../../app/');
|
||||
const { getConvo, saveConvo, getConvoTitle } = require('../../models');
|
||||
const { getConvosByPage, deleteConvos, updateConvo } = require('../../models/Conversation');
|
||||
const { getMessages } = require('../../models/Message');
|
||||
|
||||
router.get('/', async (req, res) => {
|
||||
const pageNumber = req.query.pageNumber || 1;
|
||||
res.status(200).send(await getConvos(pageNumber));
|
||||
res.status(200).send(await getConvosByPage(req?.session?.user?.username, pageNumber));
|
||||
});
|
||||
|
||||
router.get('/:conversationId', async (req, res) => {
|
||||
const { conversationId } = req.params;
|
||||
const convo = await getConvo(req?.session?.user?.username, conversationId);
|
||||
res.status(200).send(convo.toObject());
|
||||
});
|
||||
|
||||
router.post('/gen_title', async (req, res) => {
|
||||
const { conversationId } = req.body.arg;
|
||||
|
||||
const convo = await getConvo(req?.session?.user?.username, conversationId);
|
||||
const firstMessage = (await getMessages({ conversationId }))[0];
|
||||
const secondMessage = (await getMessages({ conversationId }))[1];
|
||||
|
||||
const title = convo.jailbreakConversationId
|
||||
? await getConvoTitle(req?.session?.user?.username, conversationId)
|
||||
: await titleConvo({
|
||||
model: convo?.model,
|
||||
message: firstMessage?.text,
|
||||
response: JSON.stringify(secondMessage?.text || '')
|
||||
});
|
||||
|
||||
await saveConvo(req?.session?.user?.username, {
|
||||
conversationId,
|
||||
title
|
||||
});
|
||||
|
||||
res.status(200).send(title);
|
||||
});
|
||||
|
||||
router.post('/clear', async (req, res) => {
|
||||
@@ -15,7 +47,7 @@ router.post('/clear', async (req, res) => {
|
||||
}
|
||||
|
||||
try {
|
||||
const dbResponse = await deleteConvos(filter);
|
||||
const dbResponse = await deleteConvos(req?.session?.user?.username, filter);
|
||||
res.status(201).send(dbResponse);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
@@ -27,7 +59,7 @@ router.post('/update', async (req, res) => {
|
||||
const update = req.body.arg;
|
||||
|
||||
try {
|
||||
const dbResponse = await updateConvo(update);
|
||||
const dbResponse = await updateConvo(req?.session?.user?.username, update);
|
||||
res.status(201).send(dbResponse);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { getCustomGpts, updateCustomGpt, updateByLabel, deleteCustomGpts } = require('../../models');
|
||||
const {
|
||||
getCustomGpts,
|
||||
updateCustomGpt,
|
||||
updateByLabel,
|
||||
deleteCustomGpts
|
||||
} = require('../../models');
|
||||
|
||||
router.get('/', async (req, res) => {
|
||||
const models = (await getCustomGpts()).map(model => {
|
||||
const models = (await getCustomGpts(req?.session?.user?.username)).map((model) => {
|
||||
model = model.toObject();
|
||||
model._id = model._id.toString();
|
||||
return model;
|
||||
@@ -15,8 +20,14 @@ router.post('/delete', async (req, res) => {
|
||||
const { arg } = req.body;
|
||||
|
||||
try {
|
||||
const dbResponse = await deleteCustomGpts(arg);
|
||||
res.status(201).send(dbResponse);
|
||||
await deleteCustomGpts(req?.session?.user?.username, arg);
|
||||
const models = (await getCustomGpts(req?.session?.user?.username)).map((model) => {
|
||||
model = model.toObject();
|
||||
model._id = model._id.toString();
|
||||
return model;
|
||||
});
|
||||
res.status(201).send(models);
|
||||
// res.status(201).send(dbResponse);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
res.status(500).send(error);
|
||||
@@ -45,7 +56,7 @@ router.post('/', async (req, res) => {
|
||||
}
|
||||
|
||||
try {
|
||||
const dbResponse = await setter(update);
|
||||
const dbResponse = await setter(req?.session?.user?.username, update);
|
||||
res.status(201).send(dbResponse);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
const handleError = (res, errorMessage) => {
|
||||
res.status(500).write(`event: error\ndata: ${errorMessage}`);
|
||||
const _ = require('lodash');
|
||||
const citationRegex = /\[\^\d+?\^]/g;
|
||||
const backtick = /(?<!`)[`](?!`)/g;
|
||||
// const singleBacktick = /(?<!`)[`](?!`)/;
|
||||
const cursorDefault = '<span className="result-streaming">█</span>';
|
||||
const { getCitations, citeText } = require('../../app/');
|
||||
|
||||
const handleError = (res, message) => {
|
||||
res.write(`event: error\ndata: ${JSON.stringify(message)}\n\n`);
|
||||
res.end();
|
||||
};
|
||||
|
||||
@@ -10,4 +17,80 @@ const sendMessage = (res, message) => {
|
||||
res.write(`event: message\ndata: ${JSON.stringify(message)}\n\n`);
|
||||
};
|
||||
|
||||
module.exports = { handleError, sendMessage };
|
||||
const createOnProgress = () => {
|
||||
let i = 0;
|
||||
let code = '';
|
||||
let tokens = '';
|
||||
let precode = '';
|
||||
let blockCount = 0;
|
||||
let codeBlock = false;
|
||||
let cursor = cursorDefault;
|
||||
|
||||
const progressCallback = async (partial, { res, text, bing = false, ...rest }) => {
|
||||
let chunk = partial === text ? '' : partial;
|
||||
tokens += chunk;
|
||||
precode += chunk;
|
||||
tokens = tokens.replaceAll('[DONE]', '');
|
||||
|
||||
if (codeBlock) {
|
||||
code += chunk;
|
||||
}
|
||||
|
||||
if (precode.includes('```') && codeBlock) {
|
||||
codeBlock = false;
|
||||
cursor = cursorDefault;
|
||||
precode = precode.replace(/```/g, '');
|
||||
code = '';
|
||||
}
|
||||
|
||||
if (precode.includes('```') && code === '') {
|
||||
precode = precode.replace(/```/g, '');
|
||||
codeBlock = true;
|
||||
blockCount++;
|
||||
cursor = blockCount > 1 ? '█\n\n```' : '█\n\n';
|
||||
}
|
||||
|
||||
const backticks = precode.match(backtick);
|
||||
if (backticks && !codeBlock && cursor === cursorDefault) {
|
||||
precode = precode.replace(backtick, '');
|
||||
cursor = '█';
|
||||
}
|
||||
|
||||
if (tokens.match(/^\n/)) {
|
||||
tokens = tokens.replace(/^\n/, '');
|
||||
}
|
||||
|
||||
if (bing) {
|
||||
tokens = citeText(tokens, true);
|
||||
}
|
||||
|
||||
sendMessage(res, { text: tokens + cursor, message: true, initial: i === 0, ...rest });
|
||||
i++;
|
||||
};
|
||||
|
||||
const onProgress = (model, opts) => {
|
||||
const bingModels = new Set(['bingai', 'sydney']);
|
||||
return _.partialRight(progressCallback, { ...opts, bing: bingModels.has(model) });
|
||||
};
|
||||
|
||||
return onProgress;
|
||||
};
|
||||
|
||||
const handleText = async (response, bing = false) => {
|
||||
let { text } = response;
|
||||
// text = await detectCode(text);
|
||||
response.text = text;
|
||||
|
||||
if (bing) {
|
||||
// const hasCitations = response.response.match(citationRegex)?.length > 0;
|
||||
const links = getCitations(response);
|
||||
if (response.text.match(citationRegex)?.length > 0) {
|
||||
text = citeText(response);
|
||||
}
|
||||
text += links?.length > 0 ? `\n<small>${links}</small>` : '';
|
||||
}
|
||||
|
||||
return text;
|
||||
};
|
||||
|
||||
module.exports = { handleError, sendMessage, createOnProgress, handleText };
|
||||
|
||||
@@ -2,6 +2,8 @@ const ask = require('./ask');
|
||||
const messages = require('./messages');
|
||||
const convos = require('./convos');
|
||||
const customGpts = require('./customGpts');
|
||||
const prompts = require('./prompts');
|
||||
const prompts = require('./prompts');
|
||||
const search = require('./search');
|
||||
const { router: auth, authenticatedOr401, authenticatedOrRedirect } = require('./auth');
|
||||
|
||||
module.exports = { ask, messages, convos, customGpts, prompts };
|
||||
module.exports = { search, ask, messages, convos, customGpts, prompts, auth, authenticatedOr401, authenticatedOrRedirect };
|
||||
121
api/server/routes/search.js
Normal file
121
api/server/routes/search.js
Normal file
@@ -0,0 +1,121 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { MeiliSearch } = require('meilisearch');
|
||||
const { Message } = require('../../models/Message');
|
||||
const { Conversation, getConvosQueried } = require('../../models/Conversation');
|
||||
const { reduceHits } = require('../../lib/utils/reduceHits');
|
||||
const { cleanUpPrimaryKeyValue } = require('../../lib/utils/misc');
|
||||
const cache = new Map();
|
||||
|
||||
router.get('/sync', async function (req, res) {
|
||||
await Message.syncWithMeili();
|
||||
await Conversation.syncWithMeili();
|
||||
res.send('synced');
|
||||
});
|
||||
|
||||
router.get('/', async function (req, res) {
|
||||
try {
|
||||
let user = req?.session?.user?.username;
|
||||
user = user ?? null;
|
||||
const { q } = req.query;
|
||||
const pageNumber = req.query.pageNumber || 1;
|
||||
const key = `${user || ''}${q}`;
|
||||
|
||||
if (cache.has(key)) {
|
||||
console.log('cache hit', key);
|
||||
const cached = cache.get(key);
|
||||
const { pages, pageSize, messages } = cached;
|
||||
res.status(200).send({ conversations: cached[pageNumber], pages, pageNumber, pageSize, messages });
|
||||
return;
|
||||
} else {
|
||||
cache.clear();
|
||||
}
|
||||
|
||||
// const message = await Message.meiliSearch(q);
|
||||
const messages = (
|
||||
await Message.meiliSearch(
|
||||
q,
|
||||
{
|
||||
attributesToHighlight: ['text'],
|
||||
highlightPreTag: '**',
|
||||
highlightPostTag: '**'
|
||||
},
|
||||
true
|
||||
)
|
||||
).hits.map((message) => {
|
||||
const { _formatted, ...rest } = message;
|
||||
return {
|
||||
...rest,
|
||||
searchResult: true,
|
||||
text: _formatted.text
|
||||
};
|
||||
});
|
||||
const titles = (await Conversation.meiliSearch(q)).hits;
|
||||
const sortedHits = reduceHits(messages, titles);
|
||||
// debugging:
|
||||
// console.log('user:', user, 'message hits:', messages.length, 'convo hits:', titles.length);
|
||||
// console.log('sorted hits:', sortedHits.length);
|
||||
const result = await getConvosQueried(user, sortedHits, pageNumber);
|
||||
|
||||
const activeMessages = [];
|
||||
for (let i = 0; i < messages.length; i++) {
|
||||
let message = messages[i];
|
||||
if (message.conversationId.includes('--')) {
|
||||
message.conversationId = cleanUpPrimaryKeyValue(message.conversationId);
|
||||
}
|
||||
if (result.convoMap[message.conversationId] && !message.error) {
|
||||
message = { ...message, title: result.convoMap[message.conversationId].title };
|
||||
activeMessages.push(message);
|
||||
}
|
||||
}
|
||||
result.messages = activeMessages;
|
||||
if (result.cache) {
|
||||
result.cache.messages = activeMessages;
|
||||
cache.set(key, result.cache);
|
||||
delete result.cache;
|
||||
}
|
||||
delete result.convoMap;
|
||||
// for debugging
|
||||
// console.log(result, messages.length);
|
||||
res.status(200).send(result);
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
res.status(500).send({ message: 'Error searching' });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/clear', async function (req, res) {
|
||||
await Message.resetIndex();
|
||||
res.send('cleared');
|
||||
});
|
||||
|
||||
router.get('/test', async function (req, res) {
|
||||
const { q } = req.query;
|
||||
const messages = (
|
||||
await Message.meiliSearch(q, { attributesToHighlight: ['text'] }, true)
|
||||
).hits.map((message) => {
|
||||
const { _formatted, ...rest } = message;
|
||||
return { ...rest, searchResult: true, text: _formatted.text };
|
||||
});
|
||||
res.send(messages);
|
||||
});
|
||||
|
||||
router.get('/enable', async function (req, res) {
|
||||
let result = false;
|
||||
try {
|
||||
const client = new MeiliSearch({
|
||||
host: process.env.MEILI_HOST,
|
||||
apiKey: process.env.MEILI_MASTER_KEY
|
||||
});
|
||||
|
||||
const { status } = await client.health();
|
||||
// console.log(`Meilisearch: ${status}`);
|
||||
result = status === 'available' && !!process.env.SEARCH;
|
||||
return res.send(result);
|
||||
} catch (error) {
|
||||
// console.error(error);
|
||||
return res.send(false);
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -1,2 +0,0 @@
|
||||
/node_modules
|
||||
.env
|
||||
30
client/.eslintrc.js
Normal file
30
client/.eslintrc.js
Normal file
@@ -0,0 +1,30 @@
|
||||
module.exports = {
|
||||
"env": {
|
||||
"browser": true,
|
||||
"es2021": true,
|
||||
"node": true,
|
||||
"commonjs": true,
|
||||
"es6": true,
|
||||
},
|
||||
"extends": [
|
||||
"eslint:recommended",
|
||||
"plugin:react/recommended",
|
||||
"plugin:react-hooks/recommended"
|
||||
],
|
||||
"overrides": [
|
||||
],
|
||||
"parserOptions": {
|
||||
"ecmaFeatures": {
|
||||
"jsx": true
|
||||
},
|
||||
"ecmaVersion": "latest",
|
||||
"sourceType": "module"
|
||||
},
|
||||
"plugins": [
|
||||
"react"
|
||||
],
|
||||
"rules": {
|
||||
'react/prop-types': ['off'],
|
||||
'react/display-name': ['off'],
|
||||
}
|
||||
}
|
||||
22
client/.prettierrc
Normal file
22
client/.prettierrc
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"arrowParens": "avoid",
|
||||
"bracketSpacing": true,
|
||||
"endOfLine": "lf",
|
||||
"htmlWhitespaceSensitivity": "css",
|
||||
"insertPragma": false,
|
||||
"singleAttributePerLine": true,
|
||||
"bracketSameLine": false,
|
||||
"jsxBracketSameLine": false,
|
||||
"jsxSingleQuote": false,
|
||||
"printWidth": 110,
|
||||
"proseWrap": "preserve",
|
||||
"quoteProps": "as-needed",
|
||||
"requirePragma": false,
|
||||
"semi": true,
|
||||
"singleQuote": true,
|
||||
"tabWidth": 2,
|
||||
"trailingComma": "none",
|
||||
"useTabs": false,
|
||||
"vueIndentScriptAndStyle": false,
|
||||
"parser": "babel"
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
# Stage 1
|
||||
FROM node:19-alpine as builder
|
||||
WORKDIR /client
|
||||
# copy package.json into the container at /client
|
||||
COPY package*.json /client/
|
||||
# install dependencies
|
||||
RUN npm install
|
||||
# Copy the current directory contents into the container at /client
|
||||
COPY . /client/
|
||||
# Run the app when the container launches
|
||||
CMD ["npm", "run", "build"]
|
||||
|
||||
# Stage 2
|
||||
FROM nginx:stable-alpine
|
||||
WORKDIR /usr/share/nginx/html
|
||||
RUN rm -rf ./*
|
||||
COPY --from=builder /client/public /usr/share/nginx/html
|
||||
# Add your nginx.conf
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
ENTRYPOINT ["nginx", "-g", "daemon off;"]
|
||||
|
||||
# docker build -t react-client .
|
||||
@@ -5,6 +5,7 @@ import { store } from './src/store';
|
||||
import { ThemeProvider } from './src/hooks/ThemeContext';
|
||||
import App from './src/App';
|
||||
import './src/style.css';
|
||||
import './src/mobile.css'
|
||||
|
||||
const container = document.getElementById('root');
|
||||
const root = createRoot(container);
|
||||
|
||||
2922
client/package-lock.json
generated
2922
client/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -23,24 +23,37 @@
|
||||
"@radix-ui/react-dialog": "^1.0.2",
|
||||
"@radix-ui/react-dropdown-menu": "^2.0.2",
|
||||
"@radix-ui/react-label": "^2.0.0",
|
||||
"@radix-ui/react-tabs": "^1.0.2",
|
||||
"@radix-ui/react-tabs": "^1.0.3",
|
||||
"@reduxjs/toolkit": "^1.9.2",
|
||||
"axios": "^1.3.4",
|
||||
"class-variance-authority": "^0.4.0",
|
||||
"clsx": "^1.2.1",
|
||||
"crypto-browserify": "^3.12.0",
|
||||
"highlight.js": "^11.7.0",
|
||||
"lodash": "^4.17.21",
|
||||
"lucide-react": "^0.113.0",
|
||||
"markdown-to-jsx": "^7.1.9",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-highlight": "^0.15.0",
|
||||
"react-lazy-load": "^4.0.1",
|
||||
"react-markdown": "^8.0.5",
|
||||
"react-redux": "^8.0.5",
|
||||
"react-string-replace": "^1.1.0",
|
||||
"react-textarea-autosize": "^8.4.0",
|
||||
"react-transition-group": "^4.4.5",
|
||||
"rehype-highlight": "^6.0.0",
|
||||
"rehype-katex": "^6.0.2",
|
||||
"rehype-raw": "^6.1.1",
|
||||
"remark-gfm": "^3.0.1",
|
||||
"remark-math": "^5.1.1",
|
||||
"remark-supersub": "^1.0.0",
|
||||
"swr": "^2.0.3",
|
||||
"tailwind-merge": "^1.9.1",
|
||||
"tailwindcss-animate": "^1.0.5",
|
||||
"url": "^0.11.0"
|
||||
"tailwindcss-radix": "^2.8.0",
|
||||
"url": "^0.11.0",
|
||||
"uuidv4": "^6.2.13"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/cli": "^7.20.7",
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="theme-color" content="#343541">
|
||||
<title>ChatGPT Clone</title>
|
||||
<link
|
||||
rel="shortcut icon"
|
||||
|
||||
@@ -1,35 +1,56 @@
|
||||
import React from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import Messages from './components/Messages';
|
||||
import Landing from './components/Main/Landing';
|
||||
import TextChat from './components/Main/TextChat';
|
||||
import Nav from './components/Nav';
|
||||
import MobileNav from './components/Nav/MobileNav';
|
||||
import useDocumentTitle from '~/hooks/useDocumentTitle';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import userAuth from './utils/userAuth';
|
||||
import { setUser } from './store/userReducer';
|
||||
import { setSearchState } from './store/searchSlice';
|
||||
import axios from 'axios';
|
||||
|
||||
const App = () => {
|
||||
const { messages } = useSelector((state) => state.messages);
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const { messages, messageTree } = useSelector((state) => state.messages);
|
||||
const { user } = useSelector((state) => state.user);
|
||||
const { title } = useSelector((state) => state.convo);
|
||||
const [navVisible, setNavVisible] = useState(false);
|
||||
useDocumentTitle(title);
|
||||
|
||||
return (
|
||||
<div className="flex h-screen">
|
||||
<Nav />
|
||||
<div className="flex h-full w-full flex-1 flex-col bg-gray-50 md:pl-[260px]">
|
||||
<div className="transition-width relative flex h-full w-full flex-1 flex-col items-stretch overflow-hidden bg-white dark:bg-gray-800">
|
||||
<MobileNav />
|
||||
{messages.length === 0 ? (
|
||||
<Landing title={title} />
|
||||
) : (
|
||||
<Messages
|
||||
messages={messages}
|
||||
/>
|
||||
)}
|
||||
<TextChat messages={messages} />
|
||||
useEffect(() => {
|
||||
axios.get('/api/search/enable').then((res) => { console.log(res.data); dispatch(setSearchState(res.data))});
|
||||
userAuth()
|
||||
.then((user) => dispatch(setUser(user)))
|
||||
.catch((err) => console.log(err));
|
||||
}, []);
|
||||
|
||||
if (user)
|
||||
return (
|
||||
<div className="flex h-screen">
|
||||
<Nav
|
||||
navVisible={navVisible}
|
||||
setNavVisible={setNavVisible}
|
||||
/>
|
||||
<div className="flex h-full w-full flex-1 flex-col bg-gray-50 md:pl-[260px]">
|
||||
<div className="transition-width relative flex h-full w-full flex-1 flex-col items-stretch overflow-hidden bg-white dark:bg-gray-800">
|
||||
<MobileNav setNavVisible={setNavVisible} />
|
||||
{messages.length === 0 && title.toLowerCase() === 'chatgpt clone' ? (
|
||||
<Landing title={title} />
|
||||
) : (
|
||||
<Messages
|
||||
messages={messages}
|
||||
messageTree={messageTree}
|
||||
/>
|
||||
)}
|
||||
<TextChat messages={messages} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
);
|
||||
else return <div className="flex h-screen"></div>;
|
||||
};
|
||||
|
||||
export default App;
|
||||
|
||||
@@ -3,45 +3,64 @@ import RenameButton from './RenameButton';
|
||||
import DeleteButton from './DeleteButton';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import { setConversation } from '~/store/convoSlice';
|
||||
import { setCustomGpt, setModel, setCustomModel } from '~/store/submitSlice';
|
||||
import { setMessages } from '~/store/messageSlice';
|
||||
import { setSubmission, setStopStream, setCustomGpt, setModel, setCustomModel } from '~/store/submitSlice';
|
||||
import { setMessages, setEmptyMessage } from '~/store/messageSlice';
|
||||
import { setText } from '~/store/textSlice';
|
||||
import manualSWR from '~/utils/fetchers';
|
||||
import ConvoIcon from '../svg/ConvoIcon';
|
||||
import { refreshConversation } from '../../store/convoSlice';
|
||||
|
||||
export default function Conversation({
|
||||
id,
|
||||
model,
|
||||
parentMessageId,
|
||||
conversationId,
|
||||
title = 'New conversation',
|
||||
bingData,
|
||||
title,
|
||||
chatGptLabel = null,
|
||||
promptPrefix = null,
|
||||
bingData,
|
||||
retainView,
|
||||
}) {
|
||||
const [renaming, setRenaming] = useState(false);
|
||||
const [titleInput, setTitleInput] = useState(title);
|
||||
const { modelMap } = useSelector((state) => state.models);
|
||||
const { stopStream } = useSelector((state) => state.submit);
|
||||
const inputRef = useRef(null);
|
||||
const dispatch = useDispatch();
|
||||
const { trigger } = manualSWR(`http://localhost:3080/api/messages/${id}`, 'get');
|
||||
const rename = manualSWR(`http://localhost:3080/api/convos/update`, 'post');
|
||||
const { trigger } = manualSWR(`/api/messages/${id}`, 'get');
|
||||
const rename = manualSWR(`/api/convos/update`, 'post');
|
||||
|
||||
const clickHandler = async () => {
|
||||
if (conversationId === id) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!stopStream) {
|
||||
dispatch(setStopStream(true));
|
||||
dispatch(setSubmission({}));
|
||||
}
|
||||
dispatch(setEmptyMessage());
|
||||
|
||||
const convo = { title, error: false, conversationId: id, chatGptLabel, promptPrefix };
|
||||
|
||||
if (bingData) {
|
||||
const { conversationSignature, clientId, invocationId } = bingData;
|
||||
const {
|
||||
parentMessageId,
|
||||
conversationSignature,
|
||||
jailbreakConversationId,
|
||||
clientId,
|
||||
invocationId,
|
||||
toneStyle,
|
||||
} = bingData;
|
||||
dispatch(
|
||||
setConversation({
|
||||
...convo,
|
||||
parentMessageId: null,
|
||||
parentMessageId,
|
||||
jailbreakConversationId,
|
||||
conversationSignature,
|
||||
clientId,
|
||||
invocationId
|
||||
invocationId,
|
||||
toneStyle,
|
||||
latestMessage: null
|
||||
})
|
||||
);
|
||||
} else {
|
||||
@@ -49,9 +68,12 @@ export default function Conversation({
|
||||
setConversation({
|
||||
...convo,
|
||||
parentMessageId,
|
||||
jailbreakConversationId: null,
|
||||
conversationSignature: null,
|
||||
clientId: null,
|
||||
invocationId: null
|
||||
invocationId: null,
|
||||
toneStyle: null,
|
||||
latestMessage: null
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -59,24 +81,21 @@ export default function Conversation({
|
||||
|
||||
if (chatGptLabel) {
|
||||
dispatch(setModel('chatgptCustom'));
|
||||
dispatch(setCustomModel(chatGptLabel.toLowerCase()));
|
||||
} else {
|
||||
dispatch(setModel(data[1].sender));
|
||||
}
|
||||
|
||||
if (modelMap[data[1].sender.toLowerCase()]) {
|
||||
console.log('sender', data[1].sender);
|
||||
dispatch(setCustomModel(data[1].sender.toLowerCase()));
|
||||
} else {
|
||||
dispatch(setModel(model));
|
||||
dispatch(setCustomModel(null));
|
||||
}
|
||||
|
||||
dispatch(setMessages(data));
|
||||
dispatch(setCustomGpt(convo));
|
||||
dispatch(setText(''));
|
||||
dispatch(setStopStream(false));
|
||||
};
|
||||
|
||||
const renameHandler = (e) => {
|
||||
e.preventDefault();
|
||||
setTitleInput(title);
|
||||
setRenaming(true);
|
||||
setTimeout(() => {
|
||||
inputRef.current.focus();
|
||||
@@ -94,7 +113,10 @@ export default function Conversation({
|
||||
if (titleInput === title) {
|
||||
return;
|
||||
}
|
||||
rename.trigger({ conversationId, title: titleInput });
|
||||
rename.trigger({ conversationId, title: titleInput })
|
||||
.then(() => {
|
||||
dispatch(refreshConversation())
|
||||
});
|
||||
};
|
||||
|
||||
const handleKeyDown = (e) => {
|
||||
@@ -131,7 +153,7 @@ export default function Conversation({
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
) : (
|
||||
titleInput
|
||||
title
|
||||
)}
|
||||
</div>
|
||||
{conversationId === id ? (
|
||||
@@ -146,6 +168,7 @@ export default function Conversation({
|
||||
conversationId={id}
|
||||
renaming={renaming}
|
||||
cancelHandler={cancelHandler}
|
||||
retainView={retainView}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -3,18 +3,21 @@ import TrashIcon from '../svg/TrashIcon';
|
||||
import CrossIcon from '../svg/CrossIcon';
|
||||
import manualSWR from '~/utils/fetchers';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { setConversation, removeConvo } from '~/store/convoSlice';
|
||||
import { setNewConvo, removeConvo } from '~/store/convoSlice';
|
||||
import { setMessages } from '~/store/messageSlice';
|
||||
import { setSubmission } from '~/store/submitSlice';
|
||||
|
||||
export default function DeleteButton({ conversationId, renaming, cancelHandler }) {
|
||||
export default function DeleteButton({ conversationId, renaming, cancelHandler, retainView }) {
|
||||
const dispatch = useDispatch();
|
||||
const { trigger } = manualSWR(
|
||||
`http://localhost:3080/api/convos/clear`,
|
||||
`/api/convos/clear`,
|
||||
'post',
|
||||
() => {
|
||||
dispatch(setMessages([]));
|
||||
dispatch(removeConvo(conversationId));
|
||||
dispatch(setConversation({ title: 'New chat', conversationId: null, parentMessageId: null }));
|
||||
dispatch(setNewConvo());
|
||||
dispatch(setSubmission({}));
|
||||
retainView();
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
36
client/src/components/Conversations/Pages.jsx
Normal file
36
client/src/components/Conversations/Pages.jsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import React from 'react';
|
||||
|
||||
export default function Pages({ pageNumber, pages, nextPage, previousPage }) {
|
||||
const clickHandler = (func) => async (e) => {
|
||||
e.preventDefault();
|
||||
await func();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="m-auto mt-4 mb-2 flex items-center justify-center gap-2">
|
||||
<button
|
||||
onClick={clickHandler(previousPage)}
|
||||
className={
|
||||
'btn btn-small bg-transition m-auto flex gap-2 transition hover:bg-gray-800 disabled:text-gray-300 dark:text-white dark:disabled:text-gray-400' +
|
||||
(pageNumber <= 1 ? ' hidden-visibility' : '')
|
||||
}
|
||||
disabled={pageNumber <= 1}
|
||||
>
|
||||
<<
|
||||
</button>
|
||||
<span className="flex-none text-gray-400">
|
||||
{pageNumber} / {pages}
|
||||
</span>
|
||||
<button
|
||||
onClick={clickHandler(nextPage)}
|
||||
className={
|
||||
'btn btn-small bg-transition m-auto flex gap-2 transition hover:bg-gray-800 disabled:text-gray-300 dark:text-white dark:disabled:text-gray-400' +
|
||||
(pageNumber >= pages ? ' hidden-visibility' : '')
|
||||
}
|
||||
disabled={pageNumber >= pages}
|
||||
>
|
||||
>>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,7 @@
|
||||
import React from 'react';
|
||||
import Conversation from './Conversation';
|
||||
|
||||
export default function Conversations({ conversations, conversationId, showMore }) {
|
||||
const clickHandler = async (e) => {
|
||||
e.preventDefault();
|
||||
await showMore();
|
||||
};
|
||||
export default function Conversations({ conversations, conversationId, moveToTop }) {
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -14,9 +10,12 @@ export default function Conversations({ conversations, conversationId, showMore
|
||||
conversations.map((convo) => {
|
||||
const bingData = convo.conversationSignature
|
||||
? {
|
||||
jailbreakConversationId: convo.jailbreakConversationId,
|
||||
conversationSignature: convo.conversationSignature,
|
||||
parentMessageId: convo.parentMessageId || null,
|
||||
clientId: convo.clientId,
|
||||
invocationId: convo.invocationId
|
||||
invocationId: convo.invocationId,
|
||||
toneStyle: convo.toneStyle,
|
||||
}
|
||||
: null;
|
||||
|
||||
@@ -24,23 +23,17 @@ export default function Conversations({ conversations, conversationId, showMore
|
||||
<Conversation
|
||||
key={convo.conversationId}
|
||||
id={convo.conversationId}
|
||||
model={convo.model}
|
||||
parentMessageId={convo.parentMessageId}
|
||||
title={convo.title}
|
||||
conversationId={conversationId}
|
||||
chatGptLabel={convo.chatGptLabel}
|
||||
promptPrefix={convo.promptPrefix}
|
||||
bingData={bingData}
|
||||
retainView={moveToTop}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{conversations && conversations.length >= 12 && conversations.length % 12 === 0 && (
|
||||
<button
|
||||
onClick={clickHandler}
|
||||
className="btn btn-dark btn-small m-auto mb-2 flex justify-center gap-2"
|
||||
>
|
||||
Show more
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
61
client/src/components/Main/BingStyles.jsx
Normal file
61
client/src/components/Main/BingStyles.jsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import React, { useState, useEffect, forwardRef } from 'react';
|
||||
import { Tabs, TabsList, TabsTrigger } from '../ui/Tabs.tsx';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { setConversation } from '~/store/convoSlice';
|
||||
|
||||
function BingStyles(props, ref) {
|
||||
const dispatch = useDispatch();
|
||||
const [value, setValue] = useState('fast');
|
||||
const { model } = useSelector((state) => state.submit);
|
||||
const { conversationId } = useSelector((state) => state.convo);
|
||||
const { messages } = useSelector((state) => state.messages);
|
||||
|
||||
const isBing = model === 'bingai' || model === 'sydney';
|
||||
useEffect(() => {
|
||||
if (isBing && !conversationId) {
|
||||
dispatch(setConversation({ toneStyle: value }));
|
||||
}
|
||||
}, [isBing, conversationId, model, value, dispatch]);
|
||||
|
||||
const show = isBing && (!conversationId || messages?.length === 0);
|
||||
const defaultClasses = 'p-2 rounded-md font-normal bg-white/[.60] dark:bg-gray-700 text-black';
|
||||
const defaultSelected = defaultClasses + 'font-medium data-[state=active]:text-white';
|
||||
|
||||
const selectedClass = (val) => val + '-tab ' + defaultSelected;
|
||||
|
||||
const changeHandler = value => {
|
||||
setValue(value);
|
||||
dispatch(setConversation({ toneStyle: value }));
|
||||
};
|
||||
return (
|
||||
<Tabs
|
||||
defaultValue={value}
|
||||
className={`shadow-md mb-1 bing-styles ${show ? 'show' : ''}`}
|
||||
onValueChange={changeHandler}
|
||||
ref={ref}
|
||||
>
|
||||
<TabsList className="bg-white/[.60] dark:bg-gray-700">
|
||||
<TabsTrigger
|
||||
value="creative"
|
||||
className={`${value === 'creative' ? selectedClass(value) : defaultClasses}`}
|
||||
>
|
||||
{'Creative'}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="fast"
|
||||
className={`${value === 'fast' ? selectedClass(value) : defaultClasses}`}
|
||||
>
|
||||
{'Balanced'}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="precise"
|
||||
className={`${value === 'precise' ? selectedClass(value) : defaultClasses}`}
|
||||
>
|
||||
{'Precise'}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
|
||||
export default forwardRef(BingStyles);
|
||||
@@ -2,7 +2,7 @@ import React from 'react';
|
||||
|
||||
export default function Footer() {
|
||||
return (
|
||||
<div className="px-3 pt-2 pb-3 text-center text-xs text-black/50 dark:text-white/50 md:px-4 md:pt-3 md:pb-6">
|
||||
<div className="hidden md:block px-3 pt-2 pb-1 text-center text-xs text-black/50 dark:text-white/50 md:px-4 md:pt-3 md:pb-4">
|
||||
<a
|
||||
href="https://github.com/danny-avila/chatgpt-clone"
|
||||
target="_blank"
|
||||
|
||||
@@ -26,9 +26,9 @@ export default function Landing({ title }) {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center overflow-y-auto text-sm dark:bg-gray-800">
|
||||
<div className="flex pt-10 md:pt-0 h-full flex-col items-center overflow-y-auto text-sm dark:bg-gray-800">
|
||||
<div className="w-full px-6 text-gray-800 dark:text-gray-100 md:flex md:max-w-2xl md:flex-col lg:max-w-3xl">
|
||||
<h1 className="mt-6 ml-auto mr-auto mb-10 flex items-center justify-center gap-2 text-center text-4xl font-semibold sm:mt-[20vh] sm:mb-16">
|
||||
<h1 className="mt-6 ml-auto mr-auto mb-10 flex items-center justify-center gap-2 text-center text-4xl font-semibold md:mt-[20vh] sm:mb-16">
|
||||
ChatGPT Clone
|
||||
</h1>
|
||||
<div className="items-start gap-3.5 text-center md:flex">
|
||||
|
||||
16
client/src/components/Main/RowButton.jsx
Normal file
16
client/src/components/Main/RowButton.jsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import React from 'react';
|
||||
|
||||
export default function RowButton({ onClick, children, text, className }) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`input-panel-button btn btn-neutral flex justify-center gap-2 border-0 md:border ${className}`}
|
||||
type="button"
|
||||
>
|
||||
{children}
|
||||
<span className="hidden md:block">{text}</span>
|
||||
{/* <RegenerateIcon />
|
||||
<span className="hidden md:block">Regenerate response</span> */}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
import React from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
export default function SubmitButton({ submitMessage }) {
|
||||
const { isSubmitting, disabled } = useSelector((state) => state.submit);
|
||||
export default function SubmitButton({ submitMessage, disabled }) {
|
||||
const { isSubmitting } = useSelector((state) => state.submit);
|
||||
const { error, latestMessage } = useSelector((state) => state.convo);
|
||||
|
||||
const clickHandler = (e) => {
|
||||
e.preventDefault();
|
||||
submitMessage();
|
||||
@@ -10,9 +12,12 @@ export default function SubmitButton({ submitMessage }) {
|
||||
|
||||
if (isSubmitting) {
|
||||
return (
|
||||
<button className="absolute bottom-1.5 right-1 rounded-md p-1 text-gray-500 hover:bg-gray-100 disabled:bottom-0.5 disabled:hover:bg-transparent dark:hover:bg-gray-900 dark:hover:text-gray-400 dark:disabled:hover:bg-transparent md:bottom-2.5 md:right-2 md:disabled:bottom-1">
|
||||
<button
|
||||
className="absolute bottom-0 right-1 h-[100%] w-[30px] rounded-md p-1 text-gray-500 hover:bg-gray-100 disabled:hover:bg-transparent dark:hover:bg-gray-900 dark:hover:text-gray-400 dark:disabled:hover:bg-transparent md:right-2"
|
||||
disabled
|
||||
>
|
||||
<div className="text-2xl">
|
||||
<span >·</span>
|
||||
<span>·</span>
|
||||
<span className="blink">·</span>
|
||||
<span className="blink2">·</span>
|
||||
</div>
|
||||
@@ -23,28 +28,30 @@ export default function SubmitButton({ submitMessage }) {
|
||||
<button
|
||||
onClick={clickHandler}
|
||||
disabled={disabled}
|
||||
className="absolute bottom-1.5 right-1 rounded-md p-1 text-gray-500 hover:bg-gray-100 disabled:hover:bg-transparent dark:hover:bg-gray-900 dark:hover:text-gray-400 dark:disabled:hover:bg-transparent md:bottom-2.5 md:right-2"
|
||||
className="group absolute bottom-0 right-0 flex h-[100%] w-[50px] items-center justify-center bg-transparent p-1 text-gray-500"
|
||||
>
|
||||
<svg
|
||||
stroke="currentColor"
|
||||
fill="none"
|
||||
strokeWidth="2"
|
||||
viewBox="0 0 24 24"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="mr-1 h-4 w-4"
|
||||
height="1em"
|
||||
width="1em"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<line
|
||||
x1="22"
|
||||
y1="2"
|
||||
x2="11"
|
||||
y2="13"
|
||||
/>
|
||||
<polygon points="22 2 15 22 11 13 2 9 22 2" />
|
||||
</svg>
|
||||
<div className="m-1 mr-0 rounded-md p-2 pt-[10px] pb-[10px] group-hover:bg-gray-100 group-disabled:hover:bg-transparent dark:group-hover:bg-gray-900 dark:group-hover:text-gray-400 dark:group-disabled:hover:bg-transparent">
|
||||
<svg
|
||||
stroke="currentColor"
|
||||
fill="none"
|
||||
strokeWidth="2"
|
||||
viewBox="0 0 24 24"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="mr-1 h-4 w-4 "
|
||||
height="1em"
|
||||
width="1em"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<line
|
||||
x1="22"
|
||||
y1="2"
|
||||
x2="11"
|
||||
y2="13"
|
||||
/>
|
||||
<polygon points="22 2 15 22 11 13 2 9 22 2" />
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,143 +1,329 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import { SSE } from '~/utils/sse';
|
||||
import SubmitButton from './SubmitButton';
|
||||
import Regenerate from './Regenerate';
|
||||
// import Regenerate from './Regenerate'; // not used as of Wentao's update
|
||||
import BingStyles from './BingStyles';
|
||||
import ModelMenu from '../Models/ModelMenu';
|
||||
import Footer from './Footer';
|
||||
import TextareaAutosize from 'react-textarea-autosize';
|
||||
import handleSubmit from '~/utils/handleSubmit';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import { setConversation, setError } from '~/store/convoSlice';
|
||||
import createPayload from '~/utils/createPayload';
|
||||
import RegenerateIcon from '../svg/RegenerateIcon';
|
||||
import StopGeneratingIcon from '../svg/StopGeneratingIcon';
|
||||
import { setConversation, setError, refreshConversation } from '~/store/convoSlice';
|
||||
import { setMessages } from '~/store/messageSlice';
|
||||
import { setSubmitState } from '~/store/submitSlice';
|
||||
import { setSubmitState, toggleCursor } from '~/store/submitSlice';
|
||||
import { setText } from '~/store/textSlice';
|
||||
import { useMessageHandler } from '../../utils/handleSubmit';
|
||||
|
||||
export default function TextChat({ messages }) {
|
||||
const [errorMessage, setErrorMessage] = useState('');
|
||||
const inputRef = useRef(null);
|
||||
const bingStylesRef = useRef(null);
|
||||
const isComposing = useRef(false);
|
||||
const dispatch = useDispatch();
|
||||
const convo = useSelector((state) => state.convo);
|
||||
const { initial } = useSelector((state) => state.models);
|
||||
const { isSubmitting, disabled, model, chatGptLabel, promptPrefix } = useSelector(
|
||||
(state) => state.submit
|
||||
);
|
||||
const { text } = useSelector((state) => state.text);
|
||||
const { error } = convo;
|
||||
const isCustomModel = model === 'chatgptCustom' || !initial[model];
|
||||
const convo = useSelector(state => state.convo);
|
||||
const { isSubmitting, stopStream, submission, disabled } = useSelector(state => state.submit);
|
||||
const { text } = useSelector(state => state.text);
|
||||
const { latestMessage } = convo;
|
||||
const { ask, regenerate, stopGenerating } = useMessageHandler();
|
||||
|
||||
const submitMessage = () => {
|
||||
if (error) {
|
||||
dispatch(setError(false));
|
||||
const isNotAppendable = latestMessage?.cancelled || latestMessage?.error;
|
||||
|
||||
// auto focus to input, when enter a conversation.
|
||||
useEffect(() => {
|
||||
inputRef.current?.focus();
|
||||
}, [convo?.conversationId]);
|
||||
|
||||
// controls the height of Bing tone style tabs
|
||||
useEffect(() => {
|
||||
if (!inputRef.current) {
|
||||
return; // wait for the ref to be available
|
||||
}
|
||||
|
||||
if (!!isSubmitting || text.trim() === '') {
|
||||
return;
|
||||
}
|
||||
dispatch(setSubmitState(true));
|
||||
const message = text.trim();
|
||||
const currentMsg = { sender: 'User', text: message, current: true };
|
||||
const sender = model === 'chatgptCustom' ? chatGptLabel : model;
|
||||
const initialResponse = { sender, text: '' };
|
||||
dispatch(setMessages([...messages, currentMsg, initialResponse]));
|
||||
dispatch(setText(''));
|
||||
const messageHandler = (data) => {
|
||||
dispatch(setMessages([...messages, currentMsg, { sender, text: data }]));
|
||||
};
|
||||
const convoHandler = (data) => {
|
||||
dispatch(
|
||||
setMessages([...messages, currentMsg, { sender, text: data.text || data.response }])
|
||||
);
|
||||
|
||||
if (
|
||||
model !== 'bingai' &&
|
||||
convo.conversationId === null &&
|
||||
convo.parentMessageId === null
|
||||
) {
|
||||
const { title, conversationId, id } = data;
|
||||
dispatch(
|
||||
setConversation({
|
||||
title,
|
||||
conversationId,
|
||||
parentMessageId: id,
|
||||
conversationSignature: null,
|
||||
clientId: null,
|
||||
invocationId: null,
|
||||
chatGptLabel: model === isCustomModel ? chatGptLabel : null,
|
||||
promptPrefix: model === isCustomModel ? promptPrefix : null
|
||||
})
|
||||
);
|
||||
} else if (
|
||||
model === 'bingai' &&
|
||||
convo.conversationId === null &&
|
||||
convo.invocationId === null
|
||||
) {
|
||||
const { title, conversationSignature, clientId, conversationId, invocationId } = data;
|
||||
dispatch(
|
||||
setConversation({
|
||||
title,
|
||||
conversationSignature,
|
||||
clientId,
|
||||
conversationId,
|
||||
invocationId,
|
||||
parentMessageId: null
|
||||
})
|
||||
);
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
const newHeight = inputRef.current.clientHeight;
|
||||
if (newHeight >= 24) { // 24 is the default height of the input
|
||||
bingStylesRef.current.style.bottom = 15 + newHeight + 'px';
|
||||
}
|
||||
});
|
||||
resizeObserver.observe(inputRef.current);
|
||||
return () => resizeObserver.disconnect();
|
||||
}, [inputRef]);
|
||||
|
||||
dispatch(setSubmitState(false));
|
||||
};
|
||||
const messageHandler = (data, currentState, currentMsg) => {
|
||||
const { messages, message, sender, isRegenerate } = currentState;
|
||||
|
||||
// const convoHandler = (data) => {
|
||||
// const { conversationId, id, invocationId } = data;
|
||||
// const conversationData = {
|
||||
// title: data.title,
|
||||
// conversationId,
|
||||
// parentMessageId:
|
||||
// model !== 'bingai' && !convo.conversationId && !convo.parentMessageId ? id : null,
|
||||
// conversationSignature:
|
||||
// model === 'bingai' && !convo.conversationId ? data.conversationSignature : null,
|
||||
// clientId: model === 'bingai' && !convo.conversationId ? data.clientId : null,
|
||||
// // invocationId: model === 'bingai' && !convo.conversationId ? data.invocationId : null
|
||||
// invocationId: invocationId ? invocationId : null
|
||||
// };
|
||||
// dispatch(setMessages([...messages, currentMsg, { sender: model, text: data.text || data.response }]));
|
||||
// dispatch(setConversation(conversationData));
|
||||
// dispatch(setSubmitState(false));
|
||||
// };
|
||||
|
||||
const errorHandler = (event) => {
|
||||
console.log('Error:', event);
|
||||
const errorResponse = {
|
||||
...initialResponse,
|
||||
text: `An error occurred. Please try again in a few moments.\n\nError message: ${event.data}`,
|
||||
error: true
|
||||
};
|
||||
setErrorMessage(event.data);
|
||||
dispatch(setSubmitState(false));
|
||||
dispatch(setMessages([...messages.slice(0, -2), currentMsg, errorResponse]));
|
||||
dispatch(setText(message));
|
||||
dispatch(setError(true));
|
||||
return;
|
||||
};
|
||||
const submission = {
|
||||
model,
|
||||
text: message,
|
||||
convo,
|
||||
messageHandler,
|
||||
convoHandler,
|
||||
errorHandler,
|
||||
chatGptLabel,
|
||||
promptPrefix
|
||||
};
|
||||
console.log('User Input:', message);
|
||||
handleSubmit(submission);
|
||||
if (isRegenerate)
|
||||
dispatch(
|
||||
setMessages([
|
||||
...messages,
|
||||
{
|
||||
sender,
|
||||
text: data,
|
||||
parentMessageId: message?.overrideParentMessageId,
|
||||
messageId: message?.overrideParentMessageId + '_',
|
||||
submitting: true
|
||||
}
|
||||
])
|
||||
);
|
||||
else
|
||||
dispatch(
|
||||
setMessages([
|
||||
...messages,
|
||||
currentMsg,
|
||||
{
|
||||
sender,
|
||||
text: data,
|
||||
parentMessageId: currentMsg?.messageId,
|
||||
messageId: currentMsg?.messageId + '_',
|
||||
submitting: true
|
||||
}
|
||||
])
|
||||
);
|
||||
};
|
||||
|
||||
const handleKeyDown = (e) => {
|
||||
const cancelHandler = (data, currentState, currentMsg) => {
|
||||
const { messages, message, sender, isRegenerate } = currentState;
|
||||
|
||||
if (isRegenerate)
|
||||
dispatch(
|
||||
setMessages([
|
||||
...messages,
|
||||
{
|
||||
sender,
|
||||
text: data,
|
||||
parentMessageId: message?.overrideParentMessageId,
|
||||
messageId: message?.overrideParentMessageId + '_',
|
||||
cancelled: true
|
||||
}
|
||||
])
|
||||
);
|
||||
else
|
||||
dispatch(
|
||||
setMessages([
|
||||
...messages,
|
||||
currentMsg,
|
||||
{
|
||||
sender,
|
||||
text: data,
|
||||
parentMessageId: currentMsg?.messageId,
|
||||
messageId: currentMsg?.messageId + '_',
|
||||
cancelled: true
|
||||
}
|
||||
])
|
||||
);
|
||||
};
|
||||
|
||||
const createdHandler = (data, currentState, currentMsg) => {
|
||||
const { conversationId } = currentMsg;
|
||||
dispatch(
|
||||
setConversation({
|
||||
conversationId,
|
||||
latestMessage: null
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const convoHandler = (data, currentState) => {
|
||||
const { requestMessage, responseMessage } = data;
|
||||
const { messages, message, isCustomModel, isRegenerate } = currentState;
|
||||
const { model, chatGptLabel, promptPrefix } = message;
|
||||
if (isRegenerate) dispatch(setMessages([...messages, responseMessage]));
|
||||
else dispatch(setMessages([...messages, requestMessage, responseMessage]));
|
||||
dispatch(setSubmitState(false));
|
||||
|
||||
const isBing = model === 'bingai' || model === 'sydney';
|
||||
|
||||
// refresh title
|
||||
if (requestMessage.parentMessageId == '00000000-0000-0000-0000-000000000000') {
|
||||
setTimeout(() => {
|
||||
dispatch(refreshConversation());
|
||||
}, 2000);
|
||||
|
||||
// in case it takes too long.
|
||||
setTimeout(() => {
|
||||
dispatch(refreshConversation());
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
if (!isBing && convo.conversationId === null && convo.parentMessageId === null) {
|
||||
const { title } = data;
|
||||
const { conversationId, messageId } = responseMessage;
|
||||
dispatch(
|
||||
setConversation({
|
||||
title,
|
||||
conversationId,
|
||||
parentMessageId: messageId,
|
||||
jailbreakConversationId: null,
|
||||
conversationSignature: null,
|
||||
clientId: null,
|
||||
invocationId: null,
|
||||
chatGptLabel: model === isCustomModel ? chatGptLabel : null,
|
||||
promptPrefix: model === isCustomModel ? promptPrefix : null,
|
||||
latestMessage: null
|
||||
})
|
||||
);
|
||||
} else if (model === 'bingai') {
|
||||
console.log('Bing data:', data);
|
||||
const { title } = data;
|
||||
const { conversationSignature, clientId, conversationId, invocationId, parentMessageId } =
|
||||
responseMessage;
|
||||
dispatch(
|
||||
setConversation({
|
||||
title,
|
||||
parentMessageId,
|
||||
conversationSignature,
|
||||
clientId,
|
||||
conversationId,
|
||||
invocationId,
|
||||
latestMessage: null
|
||||
})
|
||||
);
|
||||
} else if (model === 'sydney') {
|
||||
const { title } = data;
|
||||
const {
|
||||
jailbreakConversationId,
|
||||
parentMessageId,
|
||||
conversationSignature,
|
||||
clientId,
|
||||
conversationId,
|
||||
invocationId
|
||||
} = responseMessage;
|
||||
dispatch(
|
||||
setConversation({
|
||||
title,
|
||||
jailbreakConversationId,
|
||||
parentMessageId,
|
||||
conversationSignature,
|
||||
clientId,
|
||||
conversationId,
|
||||
invocationId,
|
||||
latestMessage: null
|
||||
})
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const errorHandler = (data, currentState, currentMsg) => {
|
||||
const { messages, message } = currentState;
|
||||
console.log('Error:', data);
|
||||
const errorResponse = {
|
||||
...data,
|
||||
error: true,
|
||||
parentMessageId: currentMsg?.messageId
|
||||
};
|
||||
dispatch(setSubmitState(false));
|
||||
dispatch(setMessages([...messages, currentMsg, errorResponse]));
|
||||
dispatch(setText(message?.text));
|
||||
dispatch(setError(true));
|
||||
return;
|
||||
};
|
||||
const submitMessage = () => {
|
||||
ask({ text });
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
inputRef.current?.focus();
|
||||
if (Object.keys(submission).length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentState = submission;
|
||||
|
||||
let currentMsg = { ...currentState.message };
|
||||
let latestResponseText = '';
|
||||
|
||||
const { server, payload } = createPayload(submission);
|
||||
const onMessage = e => {
|
||||
if (stopStream) {
|
||||
return;
|
||||
}
|
||||
|
||||
const data = JSON.parse(e.data);
|
||||
|
||||
if (data.final) {
|
||||
convoHandler(data, currentState);
|
||||
dispatch(toggleCursor());
|
||||
console.log('final', data);
|
||||
}
|
||||
if (data.created) {
|
||||
currentMsg = data.message;
|
||||
createdHandler(data, currentState, currentMsg);
|
||||
} else {
|
||||
let text = data.text || data.response;
|
||||
if (data.initial) {
|
||||
dispatch(toggleCursor());
|
||||
}
|
||||
if (data.message) {
|
||||
latestResponseText = text;
|
||||
messageHandler(text, currentState, currentMsg);
|
||||
}
|
||||
// console.log('dataStream', data);
|
||||
}
|
||||
};
|
||||
|
||||
const events = new SSE(server, {
|
||||
payload: JSON.stringify(payload),
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
|
||||
events.onopen = function () {
|
||||
console.log('connection is opened');
|
||||
};
|
||||
|
||||
events.onmessage = onMessage;
|
||||
|
||||
events.oncancel = () => {
|
||||
dispatch(toggleCursor(true));
|
||||
cancelHandler(latestResponseText, currentState, currentMsg);
|
||||
};
|
||||
|
||||
events.onerror = function (e) {
|
||||
console.log('error in opening conn.');
|
||||
events.close();
|
||||
|
||||
const data = JSON.parse(e.data);
|
||||
dispatch(toggleCursor(true));
|
||||
errorHandler(data, currentState, currentMsg);
|
||||
};
|
||||
|
||||
events.stream();
|
||||
|
||||
return () => {
|
||||
events.removeEventListener('message', onMessage);
|
||||
dispatch(toggleCursor(true));
|
||||
const isCancelled = events.readyState <= 1;
|
||||
events.close();
|
||||
if (isCancelled) {
|
||||
const e = new Event('cancel');
|
||||
events.dispatchEvent(e);
|
||||
}
|
||||
};
|
||||
}, [submission]);
|
||||
|
||||
const handleRegenerate = () => {
|
||||
if (latestMessage && !latestMessage?.isCreatedByUser) regenerate(latestMessage);
|
||||
};
|
||||
|
||||
const handleStopGenerating = () => {
|
||||
stopGenerating();
|
||||
};
|
||||
|
||||
const handleKeyDown = e => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
if (!isComposing.current) submitMessage();
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyUp = (e) => {
|
||||
const handleKeyUp = e => {
|
||||
if (e.keyCode === 8 && e.target.value.trim() === '') {
|
||||
dispatch(setText(e.target.value));
|
||||
}
|
||||
|
||||
if (e.key === 'Enter' && e.shiftKey) {
|
||||
return console.log('Enter + Shift');
|
||||
}
|
||||
@@ -145,39 +331,76 @@ export default function TextChat({ messages }) {
|
||||
if (isSubmitting) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
submitMessage();
|
||||
}
|
||||
};
|
||||
|
||||
const changeHandler = (e) => {
|
||||
const handleCompositionStart = () => {
|
||||
isComposing.current = true;
|
||||
};
|
||||
|
||||
const handleCompositionEnd = () => {
|
||||
isComposing.current = false;
|
||||
};
|
||||
|
||||
const changeHandler = e => {
|
||||
const { value } = e.target;
|
||||
if (isSubmitting && (value === '' || value === '\n')) {
|
||||
return;
|
||||
}
|
||||
|
||||
// if (isSubmitting && (value === '' || value === '\n')) {
|
||||
// return;
|
||||
// }
|
||||
dispatch(setText(value));
|
||||
};
|
||||
|
||||
const tryAgain = (e) => {
|
||||
e.preventDefault();
|
||||
dispatch(setError(false));
|
||||
// const tryAgain = (e) => {
|
||||
// e.preventDefault();
|
||||
// dispatch(setError(false));
|
||||
// };
|
||||
|
||||
const isSearchView = messages?.[0]?.searchResult === true;
|
||||
const getPlaceholderText = () => {
|
||||
if (isSearchView) {
|
||||
return 'Click a message title to open its conversation.';
|
||||
}
|
||||
|
||||
if (disabled) {
|
||||
return 'Choose another model or customize GPT again';
|
||||
}
|
||||
|
||||
if (isNotAppendable) {
|
||||
return 'Edit your message or Regenerate.';
|
||||
}
|
||||
|
||||
return '';
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="md:bg-vert-light-gradient dark:md:bg-vert-dark-gradient absolute bottom-0 left-0 w-full border-t bg-white dark:border-white/20 dark:bg-gray-800 md:border-t-0 md:border-transparent md:!bg-transparent md:dark:border-transparent">
|
||||
<form className="stretch mx-2 flex flex-row gap-3 pt-2 last:mb-2 md:last:mb-6 lg:mx-auto lg:max-w-3xl lg:pt-6">
|
||||
<div className="relative flex h-full flex-1 md:flex-col">
|
||||
<div className="ml-1 mt-1.5 flex justify-center gap-0 md:m-auto md:mb-2 md:w-full md:gap-2" />
|
||||
{error ? (
|
||||
<Regenerate
|
||||
submitMessage={submitMessage}
|
||||
tryAgain={tryAgain}
|
||||
errorMessage={errorMessage}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div className="input-panel md:bg-vert-light-gradient dark:md:bg-vert-dark-gradient fixed bottom-0 left-0 w-full border-t bg-white py-2 dark:border-white/20 dark:bg-gray-800 md:absolute md:border-t-0 md:border-transparent md:bg-transparent md:dark:border-transparent md:dark:bg-transparent">
|
||||
<form className="stretch mx-2 flex flex-row gap-3 last:mb-2 md:pt-2 md:last:mb-6 lg:mx-auto lg:max-w-3xl lg:pt-6">
|
||||
<div className="relative flex h-full flex-1 md:flex-col">
|
||||
<span className="order-last ml-1 flex justify-center gap-0 md:order-none md:m-auto md:mb-2 md:w-full md:gap-2">
|
||||
<BingStyles ref={bingStylesRef}/>
|
||||
{isSubmitting && !isSearchView ? (
|
||||
<button
|
||||
onClick={handleStopGenerating}
|
||||
className="input-panel-button btn btn-neutral flex justify-center gap-2 border-0 md:border"
|
||||
type="button"
|
||||
>
|
||||
<StopGeneratingIcon />
|
||||
<span className="hidden md:block">Stop generating</span>
|
||||
</button>
|
||||
) : latestMessage && !latestMessage?.isCreatedByUser && !isSearchView ? (
|
||||
<button
|
||||
onClick={handleRegenerate}
|
||||
className="input-panel-button btn btn-neutral flex justify-center gap-2 border-0 md:border"
|
||||
type="button"
|
||||
>
|
||||
<RegenerateIcon />
|
||||
<span className="hidden md:block">Regenerate response</span>
|
||||
</button>
|
||||
) : null}
|
||||
</span>
|
||||
<div
|
||||
className={`relative flex w-full flex-grow flex-col rounded-md border border-black/10 ${
|
||||
className={`relative flex flex-grow flex-col rounded-md border border-black/10 ${
|
||||
disabled ? 'bg-gray-100' : 'bg-white'
|
||||
} py-2 shadow-[0_0_10px_rgba(0,0,0,0.10)] dark:border-gray-900/50 ${
|
||||
disabled ? 'dark:bg-gray-900' : 'dark:bg-gray-700'
|
||||
@@ -186,22 +409,29 @@ export default function TextChat({ messages }) {
|
||||
<ModelMenu />
|
||||
<TextareaAutosize
|
||||
tabIndex="0"
|
||||
autoFocus
|
||||
ref={inputRef}
|
||||
// style={{maxHeight: '200px', height: '24px', overflowY: 'hidden'}}
|
||||
rows="1"
|
||||
value={text}
|
||||
value={disabled || isNotAppendable ? '' : text}
|
||||
onKeyUp={handleKeyUp}
|
||||
onKeyDown={handleKeyDown}
|
||||
onChange={changeHandler}
|
||||
placeholder={disabled ? 'Choose another model or customize GPT again' : ''}
|
||||
disabled={disabled}
|
||||
className="m-0 h-auto max-h-52 resize-none overflow-auto border-0 bg-transparent p-0 pl-9 pr-8 leading-6 focus:outline-none focus:ring-0 focus-visible:ring-0 dark:bg-transparent md:pl-8"
|
||||
onCompositionStart={handleCompositionStart}
|
||||
onCompositionEnd={handleCompositionEnd}
|
||||
placeholder={getPlaceholderText()}
|
||||
disabled={disabled || isNotAppendable}
|
||||
className="m-0 h-auto max-h-52 resize-none overflow-auto border-0 bg-transparent p-0 pl-12 pr-8 leading-6 placeholder:text-sm placeholder:text-gray-600 focus:outline-none focus:ring-0 focus-visible:ring-0 dark:bg-transparent dark:placeholder:text-gray-500 md:pl-8"
|
||||
/>
|
||||
<SubmitButton
|
||||
submitMessage={submitMessage}
|
||||
disabled={disabled || isNotAppendable}
|
||||
/>
|
||||
<SubmitButton submitMessage={submitMessage} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
<Footer />
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
<Footer />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
57
client/src/components/Messages/Content/CodeBlock.jsx
Normal file
57
client/src/components/Messages/Content/CodeBlock.jsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import React, { useRef, useState } from 'react';
|
||||
import Clipboard from '~/components/svg/Clipboard';
|
||||
import CheckMark from '~/components/svg/CheckMark';
|
||||
|
||||
const CodeBlock = ({ lang, codeChildren }) => {
|
||||
const codeRef = useRef(null);
|
||||
|
||||
return (
|
||||
<div className="rounded-md bg-black">
|
||||
<CodeBar
|
||||
lang={lang}
|
||||
codeRef={codeRef}
|
||||
/>
|
||||
<div className="overflow-y-auto p-4">
|
||||
<code
|
||||
ref={codeRef}
|
||||
className={`hljs !whitespace-pre language-${lang}`}
|
||||
>
|
||||
{codeChildren}
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const CodeBar = React.memo(({ lang, codeRef }) => {
|
||||
const [isCopied, setIsCopied] = useState(false);
|
||||
return (
|
||||
<div className="relative flex items-center rounded-tl-md rounded-tr-md bg-gray-800 px-4 py-2 font-sans text-xs text-gray-200">
|
||||
<span className="">{lang}</span>
|
||||
<button
|
||||
className="ml-auto flex gap-2"
|
||||
onClick={async () => {
|
||||
const codeString = codeRef.current?.textContent;
|
||||
if (codeString)
|
||||
navigator.clipboard.writeText(codeString).then(() => {
|
||||
setIsCopied(true);
|
||||
setTimeout(() => setIsCopied(false), 3000);
|
||||
});
|
||||
}}
|
||||
>
|
||||
{isCopied ? (
|
||||
<>
|
||||
<CheckMark />
|
||||
Copied!
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Clipboard />
|
||||
Copy code
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
export default CodeBlock;
|
||||
92
client/src/components/Messages/Content/Content.jsx
Normal file
92
client/src/components/Messages/Content/Content.jsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import React from 'react';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import rehypeKatex from 'rehype-katex';
|
||||
import rehypeHighlight from 'rehype-highlight';
|
||||
import remarkMath from 'remark-math';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import rehypeRaw from 'rehype-raw'
|
||||
import CodeBlock from './CodeBlock';
|
||||
import { langSubset } from '~/utils/languages';
|
||||
|
||||
const Content = React.memo(({ content, isCreatedByUser = true }) => {
|
||||
let rehypePlugins = [
|
||||
[rehypeKatex, { output: 'mathml' }],
|
||||
[
|
||||
rehypeHighlight,
|
||||
{
|
||||
detect: true,
|
||||
ignoreMissing: true,
|
||||
subset: langSubset
|
||||
}
|
||||
],
|
||||
[rehypeRaw],
|
||||
];
|
||||
|
||||
rehypePlugins = isCreatedByUser ? rehypePlugins.slice(0, -1) : rehypePlugins;
|
||||
|
||||
return (
|
||||
<>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm, [remarkMath, { singleDollarTextMath: false }]]}
|
||||
rehypePlugins={rehypePlugins}
|
||||
linkTarget="_new"
|
||||
components={{
|
||||
code,
|
||||
p,
|
||||
// em,
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
const code = React.memo((props) => {
|
||||
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) => {
|
||||
return <span className="whitespace-pre-wrap mb-2">{props?.children}</span>;
|
||||
});
|
||||
|
||||
// const blinker = ({ node }) => {
|
||||
// if (node.type === 'text' && node.value === '█') {
|
||||
// return <span className="result-streaming">{node.value}</span>;
|
||||
// }
|
||||
|
||||
// return null;
|
||||
// };
|
||||
|
||||
// const em = React.memo(({ node, ...props }) => {
|
||||
// if (
|
||||
// props.children[0] &&
|
||||
// typeof props.children[0] === 'string' &&
|
||||
// props.children[0].startsWith('^')
|
||||
// ) {
|
||||
// return <sup>{props.children[0].substring(1)}</sup>;
|
||||
// }
|
||||
// if (
|
||||
// props.children[0] &&
|
||||
// typeof props.children[0] === 'string' &&
|
||||
// props.children[0].startsWith('~')
|
||||
// ) {
|
||||
// return <sub>{props.children[0].substring(1)}</sub>;
|
||||
// }
|
||||
// return <i {...props} />;
|
||||
// });
|
||||
|
||||
export default Content;
|
||||
@@ -1,8 +1,8 @@
|
||||
import React, { useState } from 'react';
|
||||
import Clipboard from '../svg/Clipboard';
|
||||
import CheckMark from '../svg/CheckMark';
|
||||
import Clipboard from '~/components/svg/Clipboard';
|
||||
import CheckMark from '~/components/svg/CheckMark';
|
||||
|
||||
export default function Embed({ children, language = '', code, matched }) {
|
||||
const Embed = React.memo(({ children, lang = '', code, matched }) => {
|
||||
const [buttonText, setButtonText] = useState('Copy code');
|
||||
const isClicked = buttonText === 'Copy code';
|
||||
|
||||
@@ -18,7 +18,7 @@ export default function Embed({ children, language = '', code, matched }) {
|
||||
<pre>
|
||||
<div className="mb-4 rounded-md bg-black">
|
||||
<div className="relative flex items-center rounded-tl-md rounded-tr-md bg-gray-800 px-4 py-2 font-sans text-xs text-gray-200">
|
||||
<span className="">{language === 'javascript' && !matched ? '' : language}</span>
|
||||
<span className="">{lang === 'javascript' && !matched ? '' : lang}</span>
|
||||
<button
|
||||
className="ml-auto flex gap-2"
|
||||
onClick={clickHandler}
|
||||
@@ -32,4 +32,6 @@ export default function Embed({ children, language = '', code, matched }) {
|
||||
</div>
|
||||
</pre>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
export default Embed;
|
||||
32
client/src/components/Messages/Content/Highlight.jsx
Normal file
32
client/src/components/Messages/Content/Highlight.jsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import Highlighter from 'react-highlight';
|
||||
import hljs from 'highlight.js';
|
||||
import { languages } from '~/utils/languages';
|
||||
|
||||
const Highlight = React.memo(({ language, code }) => {
|
||||
const [highlightedCode, setHighlightedCode] = useState(code);
|
||||
const lang = languages.has(language) ? language : 'javascript';
|
||||
|
||||
useEffect(() => {
|
||||
setHighlightedCode(hljs.highlight(code, { language: lang }).value);
|
||||
}, [code, lang]);
|
||||
|
||||
return (
|
||||
<pre>
|
||||
{!highlightedCode ? (
|
||||
// <code className={`hljs !whitespace-pre language-${lang ? lang: 'javascript'}`}>
|
||||
<Highlighter className={`hljs !whitespace-pre language-${lang ? lang : 'javascript'}`}>
|
||||
{code}
|
||||
</Highlighter>
|
||||
) : (
|
||||
<code
|
||||
className={`hljs language-${lang}`}
|
||||
dangerouslySetInnerHTML={{ __html: highlightedCode }}
|
||||
/>
|
||||
)}
|
||||
</pre>
|
||||
);
|
||||
});
|
||||
|
||||
export default Highlight;
|
||||
|
||||
9
client/src/components/Messages/Content/SubRow.jsx
Normal file
9
client/src/components/Messages/Content/SubRow.jsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import React from 'react';
|
||||
|
||||
export default function SubRow({ children, classes = '', subclasses = '', onClick }) {
|
||||
return (
|
||||
<div className={`flex justify-between ${classes}`} onClick={onClick}>
|
||||
<div className={`flex items-center justify-center gap-1 self-center pt-2 text-xs ${subclasses}`}>{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
15
client/src/components/Messages/Content/TabLink.jsx
Normal file
15
client/src/components/Messages/Content/TabLink.jsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import React from 'react';
|
||||
|
||||
export default function TabLink(a) {
|
||||
return (
|
||||
<a
|
||||
href={a.href}
|
||||
title={a.title}
|
||||
className={a.className}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{a.children}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,23 @@
|
||||
import React from 'react';
|
||||
import TabLink from './TabLink';
|
||||
import Markdown from 'markdown-to-jsx';
|
||||
import Embed from './Embed';
|
||||
import Highlight from './Highlight';
|
||||
import regexSplit from '~/utils/regexSplit';
|
||||
import { wrapperRegex } from '~/utils';
|
||||
const { codeRegex, inLineRegex, markupRegex, languageMatch, newLineMatch } = wrapperRegex;
|
||||
const mdOptions = { wrapper: React.Fragment, forceWrapper: true };
|
||||
const mdOptions = {
|
||||
wrapper: React.Fragment,
|
||||
forceWrapper: true,
|
||||
overrides: {
|
||||
a: {
|
||||
component: TabLink,
|
||||
// props: {
|
||||
// className: 'foo'
|
||||
// }
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const inLineWrap = (parts) => {
|
||||
let previousElement = null;
|
||||
@@ -34,8 +46,9 @@ const inLineWrap = (parts) => {
|
||||
});
|
||||
};
|
||||
|
||||
export default function TextWrapper({ text }) {
|
||||
function TextWrapper({ text, generateCursor }) {
|
||||
let embedTest = false;
|
||||
let result = null;
|
||||
|
||||
// to match unenclosed code blocks
|
||||
if (text.match(/```/g)?.length === 1) {
|
||||
@@ -125,13 +138,25 @@ export default function TextWrapper({ text }) {
|
||||
}
|
||||
});
|
||||
|
||||
return <>{codeParts}</>; // return the wrapped text
|
||||
// return <>{codeParts}</>; // return the wrapped text
|
||||
result = <>{codeParts}</>;
|
||||
} else if (text.match(markupRegex)) {
|
||||
// map over the parts and wrap any text between tildes with <code> tags
|
||||
const parts = text.split(markupRegex);
|
||||
const codeParts = inLineWrap(parts);
|
||||
return <>{codeParts}</>; // return the wrapped text
|
||||
// return <>{codeParts}</>; // return the wrapped text
|
||||
result = <>{codeParts}</>;
|
||||
} else {
|
||||
return <Markdown options={mdOptions}>{text}</Markdown>;
|
||||
// return <Markdown options={mdOptions}>{text}</Markdown>;
|
||||
result = <Markdown options={mdOptions}>{text}</Markdown>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{result}
|
||||
{generateCursor()}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(TextWrapper);
|
||||
25
client/src/components/Messages/Content/Wrapper.jsx
Normal file
25
client/src/components/Messages/Content/Wrapper.jsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import React from 'react';
|
||||
import TextWrapper from './TextWrapper';
|
||||
import Content from './Content';
|
||||
|
||||
const Wrapper = React.memo(({ text, generateCursor, isCreatedByUser, searchResult }) => {
|
||||
if (searchResult) {
|
||||
return (
|
||||
<Content
|
||||
content={text}
|
||||
isCreatedByUser={isCreatedByUser}
|
||||
/>
|
||||
);
|
||||
} else if (!isCreatedByUser) {
|
||||
return (
|
||||
<TextWrapper
|
||||
text={text}
|
||||
generateCursor={generateCursor}
|
||||
/>
|
||||
);
|
||||
} else if (isCreatedByUser) {
|
||||
return <>{text}</>;
|
||||
}
|
||||
});
|
||||
|
||||
export default Wrapper;
|
||||
@@ -1,16 +0,0 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import hljs from 'highlight.js';
|
||||
|
||||
export default function Highlight({language, code}) {
|
||||
const [highlightedCode, setHighlightedCode] = useState(code);
|
||||
|
||||
useEffect(() => {
|
||||
setHighlightedCode(hljs.highlight(code, { language }).value);
|
||||
}, [code, language]);
|
||||
|
||||
return (
|
||||
<pre>
|
||||
<code className={`language-${language}`} dangerouslySetInnerHTML={{__html: highlightedCode}}/>
|
||||
</pre>
|
||||
);
|
||||
}
|
||||
@@ -2,18 +2,24 @@ import React from 'react';
|
||||
// import Clipboard from '../svg/Clipboard';
|
||||
import EditIcon from '../svg/EditIcon';
|
||||
|
||||
export default function HoverButtons({ user }) {
|
||||
return (
|
||||
export default function HoverButtons({ visible, onClick, model }) {
|
||||
const isBing = model === 'bingai';
|
||||
const enabled = !isBing;
|
||||
|
||||
<div className="visible mt-2 flex justify-center gap-3 self-end text-gray-400 md:gap-4 lg:absolute lg:top-0 lg:right-0 lg:mt-0 lg:translate-x-full lg:gap-1 lg:self-center lg:pl-2">
|
||||
{user && (
|
||||
<button className="rounded-md p-1 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">
|
||||
return (
|
||||
<div className="visible mt-2 flex justify-center gap-3 self-end text-gray-400 md:gap-4 lg:absolute lg:top-0 lg:right-0 lg:mt-0 lg:translate-x-full lg:gap-1 lg:self-center lg:pl-2">
|
||||
{(visible&&enabled)?(
|
||||
<>
|
||||
<button className="resubmit-edit-button rounded-md p-1 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}>
|
||||
{/* <button className="rounded-md p-1 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"> */}
|
||||
<EditIcon />
|
||||
</button>
|
||||
)}
|
||||
{/* <button className="rounded-md p-1 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">
|
||||
</>
|
||||
):null}
|
||||
{/* <button className="rounded-md p-1 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">
|
||||
<Clipboard />
|
||||
</button> */}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,28 +1,67 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import TextWrapper from './TextWrapper';
|
||||
import { useSelector } from 'react-redux';
|
||||
import GPTIcon from '../svg/GPTIcon';
|
||||
import BingIcon from '../svg/BingIcon';
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import SubRow from './Content/SubRow';
|
||||
import Wrapper from './Content/Wrapper';
|
||||
import MultiMessage from './MultiMessage';
|
||||
import HoverButtons from './HoverButtons';
|
||||
import SiblingSwitch from './SiblingSwitch';
|
||||
import { setConversation, setLatestMessage } from '~/store/convoSlice';
|
||||
import { setModel, setCustomModel, setCustomGpt, setDisabled } from '~/store/submitSlice';
|
||||
import { setMessages } from '~/store/messageSlice';
|
||||
import { fetchById } from '~/utils/fetchers';
|
||||
import { getIconOfModel } from '~/utils';
|
||||
import { useMessageHandler } from '~/utils/handleSubmit';
|
||||
|
||||
export default function Message({
|
||||
sender,
|
||||
text,
|
||||
last = false,
|
||||
error = false,
|
||||
scrollToBottom
|
||||
message,
|
||||
messages,
|
||||
scrollToBottom,
|
||||
currentEditId,
|
||||
setCurrentEditId,
|
||||
siblingIdx,
|
||||
siblingCount,
|
||||
setSiblingIdx
|
||||
}) {
|
||||
const { isSubmitting } = useSelector((state) => state.submit);
|
||||
const { isSubmitting, model, chatGptLabel, cursor, promptPrefix } = useSelector(state => state.submit);
|
||||
const [abortScroll, setAbort] = useState(false);
|
||||
const [isHovering, setIsHovering] = useState(false);
|
||||
const notUser = sender.toLowerCase() !== 'user';
|
||||
const blinker = isSubmitting && last && notUser;
|
||||
const { sender, text, searchResult, isCreatedByUser, error, submitting } = message;
|
||||
const textEditor = useRef(null);
|
||||
const last = !message?.children?.length;
|
||||
const edit = message.messageId == currentEditId;
|
||||
const { ask } = useMessageHandler();
|
||||
const dispatch = useDispatch();
|
||||
// const currentConvo = convoMap[message.conversationId];
|
||||
|
||||
// const notUser = !isCreatedByUser; // sender.toLowerCase() !== 'user';
|
||||
// const blinker = submitting && isSubmitting && last && !isCreatedByUser;
|
||||
const blinker = submitting && isSubmitting;
|
||||
const generateCursor = useCallback(() => {
|
||||
if (!blinker) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (!cursor) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return <span className="result-streaming">█</span>;
|
||||
}, [blinker, cursor]);
|
||||
|
||||
useEffect(() => {
|
||||
if (blinker && !abortScroll) {
|
||||
scrollToBottom();
|
||||
}
|
||||
}, [isSubmitting, text, blinker, scrollToBottom, abortScroll]);
|
||||
}, [isSubmitting, blinker, text, scrollToBottom]);
|
||||
|
||||
useEffect(() => {
|
||||
if (last) {
|
||||
// TODO: stop using conversation.parentMessageId and remove it.
|
||||
dispatch(setConversation({ parentMessageId: message?.messageId }));
|
||||
dispatch(setLatestMessage({ ...message }));
|
||||
}
|
||||
}, [last, message]);
|
||||
|
||||
const enterEdit = cancel => setCurrentEditId(cancel ? -1 : message.messageId);
|
||||
|
||||
const handleWheel = () => {
|
||||
if (blinker) {
|
||||
@@ -32,85 +71,166 @@ export default function Message({
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseOver = () => {
|
||||
setIsHovering(true);
|
||||
};
|
||||
|
||||
const handleMouseOut = () => {
|
||||
setIsHovering(false);
|
||||
};
|
||||
|
||||
const props = {
|
||||
className:
|
||||
'w-full border-b border-black/10 dark:border-gray-900/50 text-gray-800 bg-white dark:text-gray-100 group dark:bg-gray-800'
|
||||
};
|
||||
|
||||
const bgColors = {
|
||||
chatgpt: 'rgb(16, 163, 127)',
|
||||
chatgptBrowser: 'rgb(25, 207, 207)',
|
||||
bingai: ''
|
||||
};
|
||||
const icon = getIconOfModel({
|
||||
sender,
|
||||
isCreatedByUser,
|
||||
model,
|
||||
searchResult,
|
||||
chatGptLabel,
|
||||
promptPrefix,
|
||||
error
|
||||
});
|
||||
|
||||
let icon = `${sender}:`;
|
||||
let backgroundColor = bgColors[sender];
|
||||
|
||||
if (notUser) {
|
||||
if (!isCreatedByUser)
|
||||
props.className =
|
||||
'w-full border-b border-black/10 bg-gray-50 dark:border-gray-900/50 text-gray-800 dark:text-gray-100 group bg-gray-100 dark:bg-[#444654]';
|
||||
|
||||
if (message.bg && searchResult) {
|
||||
props.className = message.bg.split('hover')[0];
|
||||
props.titleClass = message.bg.split(props.className)[1] + ' cursor-pointer';
|
||||
}
|
||||
|
||||
if ((notUser && backgroundColor) || sender === 'bingai') {
|
||||
icon = (
|
||||
<div
|
||||
style={{ backgroundColor }}
|
||||
className="relative flex h-[30px] w-[30px] items-center justify-center rounded-sm p-1 text-white"
|
||||
>
|
||||
{sender === 'bingai' ? <BingIcon /> : <GPTIcon />}
|
||||
{error && (
|
||||
<span className="absolute right-0 top-[20px] -mr-2 flex h-4 w-4 items-center justify-center rounded-full border border-white bg-red-500 text-[10px] text-white">
|
||||
!
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const resubmitMessage = () => {
|
||||
const text = textEditor.current.innerText;
|
||||
|
||||
const wrapText = (text) => <TextWrapper text={text} />;
|
||||
ask({
|
||||
text,
|
||||
parentMessageId: message?.parentMessageId,
|
||||
conversationId: message?.conversationId
|
||||
});
|
||||
|
||||
setSiblingIdx(siblingCount - 1);
|
||||
enterEdit(true);
|
||||
};
|
||||
|
||||
const clickSearchResult = async () => {
|
||||
if (!searchResult) return;
|
||||
dispatch(setMessages([]));
|
||||
const convoResponse = await fetchById('convos', message.conversationId);
|
||||
const convo = convoResponse.data;
|
||||
if (convo?.chatGptLabel) {
|
||||
dispatch(setModel('chatgptCustom'));
|
||||
dispatch(setCustomModel(convo.chatGptLabel.toLowerCase()));
|
||||
} else {
|
||||
dispatch(setModel(convo.model));
|
||||
dispatch(setCustomModel(null));
|
||||
}
|
||||
|
||||
dispatch(setCustomGpt(convo));
|
||||
dispatch(setConversation(convo));
|
||||
const { data } = await fetchById('messages', message.conversationId);
|
||||
dispatch(setMessages(data));
|
||||
dispatch(setDisabled(false));
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
{...props}
|
||||
onWheel={handleWheel}
|
||||
onMouseOver={handleMouseOver}
|
||||
onMouseOut={handleMouseOut}
|
||||
>
|
||||
<div className="m-auto flex gap-4 p-4 text-base md:max-w-2xl md:gap-6 md:py-6 lg:max-w-2xl lg:px-0 xl:max-w-3xl">
|
||||
<strong className="relative flex w-[30px] flex-col items-end text-right">
|
||||
{icon}
|
||||
</strong>
|
||||
<div className="relative flex w-[calc(100%-50px)] flex-col gap-1 whitespace-pre-wrap md:gap-3 lg:w-[calc(100%-115px)]">
|
||||
<div className="flex flex-grow flex-col gap-3">
|
||||
{error ? (
|
||||
<div className="flex flex min-h-[20px] flex-row flex-col items-start gap-4 gap-2 whitespace-pre-wrap text-red-500">
|
||||
<div className="rounded-md border border-red-500 bg-red-500/10 py-2 px-3 text-sm text-gray-600 dark:text-gray-100">
|
||||
{text}
|
||||
</div>
|
||||
</div>
|
||||
<>
|
||||
<div
|
||||
{...props}
|
||||
onWheel={handleWheel}
|
||||
// onClick={clickSearchResult}
|
||||
>
|
||||
<div className="relative m-auto flex gap-4 p-4 text-base md:max-w-2xl md:gap-6 md:py-6 lg:max-w-2xl lg:px-0 xl:max-w-3xl">
|
||||
<div className="relative flex h-[30px] w-[30px] flex-col items-end text-right text-xs md:text-sm">
|
||||
{typeof icon === 'string' && icon.match(/[^\\x00-\\x7F]+/) ? (
|
||||
<span className=" direction-rtl w-40 overflow-x-scroll">{icon}</span>
|
||||
) : (
|
||||
<div className="flex min-h-[20px] flex-col items-start gap-4 whitespace-pre-wrap">
|
||||
{/* <div className={`${blinker ? 'result-streaming' : ''} markdown prose dark:prose-invert light w-full break-words`}> */}
|
||||
<div className="markdown prose dark:prose-invert light w-full break-words">
|
||||
{notUser ? wrapText(text) : text}
|
||||
{blinker && <span className="result-streaming">█</span>}
|
||||
</div>
|
||||
</div>
|
||||
icon
|
||||
)}
|
||||
<div className="sibling-switch invisible absolute left-0 top-2 -ml-4 flex -translate-x-full items-center justify-center gap-1 text-xs group-hover:visible">
|
||||
<SiblingSwitch
|
||||
siblingIdx={siblingIdx}
|
||||
siblingCount={siblingCount}
|
||||
setSiblingIdx={setSiblingIdx}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
{isHovering && <HoverButtons user={!notUser} />}
|
||||
<div className="relative flex w-[calc(100%-50px)] flex-col gap-1 whitespace-pre-wrap md:gap-3 lg:w-[calc(100%-115px)]">
|
||||
{searchResult && (
|
||||
<SubRow
|
||||
classes={props.titleClass + ' rounded'}
|
||||
subclasses="switch-result pl-2 pb-2"
|
||||
onClick={clickSearchResult}
|
||||
>
|
||||
<strong>{`${message.title} | ${message.sender}`}</strong>
|
||||
</SubRow>
|
||||
)}
|
||||
<div className="flex flex-grow flex-col gap-3">
|
||||
{error ? (
|
||||
<div className="flex flex min-h-[20px] flex-grow flex-col items-start gap-4 gap-2 whitespace-pre-wrap text-red-500">
|
||||
<div className="rounded-md border border-red-500 bg-red-500/10 py-2 px-3 text-sm text-gray-600 dark:text-gray-100">
|
||||
{`An error occurred. Please try again in a few moments.\n\nError message: ${text}`}
|
||||
</div>
|
||||
</div>
|
||||
) : edit ? (
|
||||
<div className="flex min-h-[20px] flex-grow flex-col items-start gap-4 whitespace-pre-wrap">
|
||||
{/* <div className={`${blinker ? 'result-streaming' : ''} markdown prose dark:prose-invert light w-full break-words`}> */}
|
||||
|
||||
<div
|
||||
className="markdown prose dark:prose-invert light w-full break-words border-none focus:outline-none"
|
||||
contentEditable={true}
|
||||
ref={textEditor}
|
||||
suppressContentEditableWarning={true}
|
||||
>
|
||||
{text}
|
||||
</div>
|
||||
<div className="mt-2 flex w-full justify-center text-center">
|
||||
<button
|
||||
className="btn btn-primary relative mr-2"
|
||||
disabled={isSubmitting}
|
||||
onClick={resubmitMessage}
|
||||
>
|
||||
Save & Submit
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-neutral relative"
|
||||
onClick={() => enterEdit(true)}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex min-h-[20px] flex-grow flex-col items-start gap-4 whitespace-pre-wrap">
|
||||
{/* <div className={`${blinker ? 'result-streaming' : ''} markdown prose dark:prose-invert light w-full break-words`}> */}
|
||||
<div className="markdown prose dark:prose-invert light w-full break-words">
|
||||
<Wrapper
|
||||
text={text}
|
||||
generateCursor={generateCursor}
|
||||
isCreatedByUser={isCreatedByUser}
|
||||
searchResult={searchResult}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<HoverButtons
|
||||
model={model}
|
||||
visible={!error && isCreatedByUser && !edit && !searchResult}
|
||||
onClick={() => enterEdit()}
|
||||
/>
|
||||
<SubRow subclasses="switch-container">
|
||||
<SiblingSwitch
|
||||
siblingIdx={siblingIdx}
|
||||
siblingCount={siblingCount}
|
||||
setSiblingIdx={setSiblingIdx}
|
||||
/>
|
||||
</SubRow>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<MultiMessage
|
||||
messageList={message.children}
|
||||
messages={messages}
|
||||
scrollToBottom={scrollToBottom}
|
||||
currentEditId={currentEditId}
|
||||
setCurrentEditId={setCurrentEditId}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
38
client/src/components/Messages/MessageBar.jsx
Normal file
38
client/src/components/Messages/MessageBar.jsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import React, { useRef, useEffect, useState } from 'react';
|
||||
|
||||
const MessageBar = ({ children, dynamicProps, handleWheel, clickSearchResult }) => {
|
||||
const ref = useRef(null);
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
if (entry.isIntersecting) {
|
||||
setIsVisible(true);
|
||||
observer.unobserve(ref.current);
|
||||
}
|
||||
},
|
||||
{ threshold: 0.1 }
|
||||
);
|
||||
|
||||
observer.observe(ref.current);
|
||||
|
||||
return () => {
|
||||
observer.unobserve(ref.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
{...dynamicProps}
|
||||
onWheel={handleWheel}
|
||||
// onClick={clickSearchResult}
|
||||
|
||||
ref={ref}
|
||||
>
|
||||
{isVisible ? children : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MessageBar;
|
||||
46
client/src/components/Messages/MultiMessage.jsx
Normal file
46
client/src/components/Messages/MultiMessage.jsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import Message from './Message';
|
||||
|
||||
export default function MultiMessage({
|
||||
messageList,
|
||||
messages,
|
||||
scrollToBottom,
|
||||
currentEditId,
|
||||
setCurrentEditId,
|
||||
}) {
|
||||
const [siblingIdx, setSiblingIdx] = useState(0);
|
||||
|
||||
const setSiblingIdxRev = (value) => {
|
||||
setSiblingIdx(messageList?.length - value - 1);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// reset siblingIdx when changes, mostly a new message is submitting.
|
||||
setSiblingIdx(0);
|
||||
}, [messageList?.length])
|
||||
|
||||
// if (!messageList?.length) return null;
|
||||
if (!(messageList && messageList.length)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (siblingIdx >= messageList?.length) {
|
||||
setSiblingIdx(0);
|
||||
return null;
|
||||
}
|
||||
|
||||
const message = messageList[messageList.length - siblingIdx - 1];
|
||||
return (
|
||||
<Message
|
||||
key={message.messageId}
|
||||
message={message}
|
||||
messages={messages}
|
||||
scrollToBottom={scrollToBottom}
|
||||
currentEditId={currentEditId}
|
||||
setCurrentEditId={setCurrentEditId}
|
||||
siblingIdx={messageList.length - siblingIdx - 1}
|
||||
siblingCount={messageList.length}
|
||||
setSiblingIdx={setSiblingIdxRev}
|
||||
/>
|
||||
);
|
||||
}
|
||||
26
client/src/components/Messages/SiblingSwitch.jsx
Normal file
26
client/src/components/Messages/SiblingSwitch.jsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import React from 'react';
|
||||
|
||||
export default function SiblingSwitch({
|
||||
siblingIdx,
|
||||
siblingCount,
|
||||
setSiblingIdx
|
||||
}) {
|
||||
const previous = () => {
|
||||
setSiblingIdx(siblingIdx - 1);
|
||||
}
|
||||
|
||||
const next = () => {
|
||||
setSiblingIdx(siblingIdx + 1);
|
||||
}
|
||||
return siblingCount > 1 ? (
|
||||
<>
|
||||
<button className="dark:text-white disabled:text-gray-300 dark:disabled:text-gray-400" onClick={previous} disabled={siblingIdx==0}>
|
||||
<svg stroke="currentColor" fill="none" strokeWidth="1.5" viewBox="0 0 24 24" strokeLinecap="round" strokeLinejoin="round" className="h-3 w-3" height="1em" width="1em" xmlns="http://www.w3.org/2000/svg"><polyline points="15 18 9 12 15 6"></polyline></svg>
|
||||
</button>
|
||||
<span className="flex-grow flex-shrink-0">{siblingIdx + 1}/{siblingCount}</span>
|
||||
<button className="dark:text-white disabled:text-gray-300 dark:disabled:text-gray-400" onClick={next} disabled={siblingIdx==siblingCount-1}>
|
||||
<svg stroke="currentColor" fill="none" strokeWidth="1.5" viewBox="0 0 24 24" strokeLinecap="round" strokeLinejoin="round" className="h-3 w-3" height="1em" width="1em" xmlns="http://www.w3.org/2000/svg"><polyline points="9 18 15 12 9 6"></polyline></svg>
|
||||
</button>
|
||||
</>
|
||||
):null;
|
||||
}
|
||||
@@ -1,36 +1,50 @@
|
||||
import React, { useEffect, useState, useRef } from 'react';
|
||||
import React, { useEffect, useState, useRef, useCallback } from 'react';
|
||||
import Spinner from '../svg/Spinner';
|
||||
import { throttle } from 'lodash';
|
||||
import { CSSTransition } from 'react-transition-group';
|
||||
import ScrollToBottom from './ScrollToBottom';
|
||||
import Message from './Message';
|
||||
import MultiMessage from './MultiMessage';
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
const Messages = ({ messages }) => {
|
||||
export default function Messages({ messages, messageTree }) {
|
||||
const [currentEditId, setCurrentEditId] = useState(-1);
|
||||
const { conversationId } = useSelector((state) => state.convo);
|
||||
const { model, customModel } = useSelector((state) => state.submit);
|
||||
const { models } = useSelector((state) => state.models);
|
||||
const [showScrollButton, setShowScrollButton] = useState(false);
|
||||
const scrollableRef = useRef(null);
|
||||
const messagesEndRef = useRef(null);
|
||||
|
||||
const modelName = models.find((element) => element.model == model)?.name;
|
||||
|
||||
useEffect(() => {
|
||||
const timeoutId = setTimeout(() => {
|
||||
const scrollable = scrollableRef.current;
|
||||
const hasScrollbar = scrollable.scrollHeight > scrollable.clientHeight;
|
||||
const { scrollTop, scrollHeight, clientHeight } = scrollableRef.current;
|
||||
const diff = Math.abs(scrollHeight - scrollTop);
|
||||
const percent = Math.abs(clientHeight - diff ) / clientHeight;
|
||||
const hasScrollbar = scrollHeight > clientHeight && percent > 0.2;
|
||||
setShowScrollButton(hasScrollbar);
|
||||
}, 650);
|
||||
|
||||
// Add a listener on the window object
|
||||
window.addEventListener('scroll', handleScroll);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timeoutId);
|
||||
window.removeEventListener('scroll', handleScroll);
|
||||
};
|
||||
}, [messages]);
|
||||
|
||||
const scrollToBottom = () => {
|
||||
const scrollToBottom = useCallback(throttle(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
setShowScrollButton(false);
|
||||
};
|
||||
}, 750, { leading: true }), [messagesEndRef]);
|
||||
|
||||
const handleScroll = () => {
|
||||
const { scrollTop, scrollHeight, clientHeight } = scrollableRef.current;
|
||||
const diff = Math.abs(scrollHeight - scrollTop);
|
||||
const bottom =
|
||||
diff === clientHeight || (diff <= clientHeight + 25 && diff >= clientHeight - 25);
|
||||
if (bottom) {
|
||||
const percent = Math.abs(clientHeight - diff ) / clientHeight;
|
||||
if (percent <= 0.2) {
|
||||
setShowScrollButton(false);
|
||||
} else {
|
||||
setShowScrollButton(true);
|
||||
@@ -50,35 +64,41 @@ const Messages = ({ messages }) => {
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex-1 overflow-y-auto "
|
||||
className="flex-1 overflow-y-auto pt-10 md:pt-0"
|
||||
ref={scrollableRef}
|
||||
onScroll={debouncedHandleScroll}
|
||||
>
|
||||
{/* <div className="flex-1 overflow-hidden"> */}
|
||||
<div className="h-full dark:gpt-dark-gray">
|
||||
<div className="flex h-full flex-col items-center text-sm dark:gpt-dark-gray">
|
||||
{messages.map((message, i) => (
|
||||
<Message
|
||||
key={i}
|
||||
sender={message.sender}
|
||||
text={message.text}
|
||||
last={i === messages.length - 1}
|
||||
error={message.error ? true : false}
|
||||
scrollToBottom={i === messages.length - 1 ? scrollToBottom : null}
|
||||
/>
|
||||
))}
|
||||
<CSSTransition
|
||||
in={showScrollButton}
|
||||
timeout={400}
|
||||
classNames="scroll-down"
|
||||
unmountOnExit={false}
|
||||
// appear
|
||||
>
|
||||
{() => showScrollButton && <ScrollToBottom scrollHandler={scrollHandler} />}
|
||||
</CSSTransition>
|
||||
|
||||
<div className="dark:gpt-dark-gray h-full">
|
||||
<div className="dark:gpt-dark-gray flex h-full flex-col items-center text-sm">
|
||||
<div className="flex w-full items-center justify-center gap-1 border-b border-black/10 bg-gray-50 p-3 text-sm text-gray-500 dark:border-gray-900/50 dark:bg-gray-700 dark:text-gray-300">
|
||||
Model: {modelName} {customModel ? `(${customModel})` : null}
|
||||
</div>
|
||||
{(messageTree.length === 0 || !messages) ? (
|
||||
<Spinner />
|
||||
) : (
|
||||
<>
|
||||
<MultiMessage
|
||||
key={conversationId} // avoid internal state mixture
|
||||
messageList={messageTree}
|
||||
messages={messages}
|
||||
scrollToBottom={scrollToBottom}
|
||||
currentEditId={currentEditId}
|
||||
setCurrentEditId={setCurrentEditId}
|
||||
/>
|
||||
<CSSTransition
|
||||
in={showScrollButton}
|
||||
timeout={400}
|
||||
classNames="scroll-down"
|
||||
unmountOnExit={false}
|
||||
// appear
|
||||
>
|
||||
{() => showScrollButton && <ScrollToBottom scrollHandler={scrollHandler} />}
|
||||
</CSSTransition>
|
||||
</>
|
||||
)}
|
||||
<div
|
||||
className="group h-32 w-full flex-shrink-0 dark:border-gray-900/50 dark:gpt-dark-gray md:h-48"
|
||||
className="dark:gpt-dark-gray group h-32 w-full flex-shrink-0 dark:border-gray-900/50 md:h-48"
|
||||
ref={messagesEndRef}
|
||||
/>
|
||||
</div>
|
||||
@@ -86,6 +106,4 @@ const Messages = ({ messages }) => {
|
||||
{/* </div> */}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Messages;
|
||||
}
|
||||
|
||||
@@ -4,12 +4,16 @@ import ModelItem from './ModelItem';
|
||||
export default function MenuItems({ models, onSelect }) {
|
||||
return (
|
||||
<>
|
||||
{models.map((modelItem, i) => (
|
||||
{models.map((modelItem) => (
|
||||
<ModelItem
|
||||
key={i}
|
||||
key={modelItem._id}
|
||||
id={modelItem._id}
|
||||
modelName={modelItem.name}
|
||||
value={modelItem.value}
|
||||
model={modelItem.model || 'chatgptCustom'}
|
||||
onSelect={onSelect}
|
||||
chatGptLabel={modelItem.chatGptLabel}
|
||||
promptPrefix={modelItem.promptPrefix}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import React, { useState, useRef } from 'react';
|
||||
import TextareaAutosize from 'react-textarea-autosize';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import { setModel, setCustomGpt } from '~/store/submitSlice';
|
||||
import { setSubmission, setModel, setCustomGpt } from '~/store/submitSlice';
|
||||
import { setNewConvo } from '~/store/convoSlice';
|
||||
import manualSWR from '~/utils/fetchers';
|
||||
import { Button } from '../ui/Button.tsx';
|
||||
import { Input } from '../ui/Input.tsx';
|
||||
@@ -24,9 +25,9 @@ export default function ModelDialog({ mutate, setModelSave, handleSaveState }) {
|
||||
const [saveText, setSaveText] = useState('Save');
|
||||
const [required, setRequired] = useState(false);
|
||||
const inputRef = useRef(null);
|
||||
const updateCustomGpt = manualSWR(`http://localhost:3080/api/customGpts/`, 'post');
|
||||
const updateCustomGpt = manualSWR(`/api/customGpts/`, 'post');
|
||||
|
||||
const submitHandler = (e) => {
|
||||
const selectHandler = (e) => {
|
||||
if (chatGptLabel.length === 0) {
|
||||
e.preventDefault();
|
||||
setRequired(true);
|
||||
@@ -36,7 +37,9 @@ export default function ModelDialog({ mutate, setModelSave, handleSaveState }) {
|
||||
dispatch(setCustomGpt({ chatGptLabel, promptPrefix }));
|
||||
dispatch(setModel('chatgptCustom'));
|
||||
handleSaveState(chatGptLabel.toLowerCase());
|
||||
// dispatch(setDisabled(false));
|
||||
// Set new conversation
|
||||
dispatch(setNewConvo());
|
||||
dispatch(setSubmission({}));
|
||||
};
|
||||
|
||||
const saveHandler = (e) => {
|
||||
@@ -137,10 +140,10 @@ export default function ModelDialog({ mutate, setModelSave, handleSaveState }) {
|
||||
{saveText}
|
||||
</Button>
|
||||
<DialogClose
|
||||
onClick={submitHandler}
|
||||
onClick={selectHandler}
|
||||
className="inline-flex h-10 items-center justify-center rounded-md border-none bg-gray-900 py-2 px-4 text-sm font-semibold text-white transition-colors hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-gray-400 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-gray-100 dark:text-gray-900 dark:hover:bg-gray-200 dark:focus:ring-gray-400 dark:focus:ring-offset-gray-900"
|
||||
>
|
||||
Submit
|
||||
Select
|
||||
</DialogClose>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import React, { useState, useRef } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { DropdownMenuRadioItem } from '../ui/DropdownMenu.tsx';
|
||||
import { setModels } from '~/store/modelSlice';
|
||||
import { Circle } from 'lucide-react';
|
||||
import { DialogTrigger } from '../ui/Dialog.tsx';
|
||||
import RenameButton from '../Conversations/RenameButton';
|
||||
import TrashIcon from '../svg/TrashIcon';
|
||||
import manualSWR from '~/utils/fetchers';
|
||||
import { getIconOfModel } from '../../utils';
|
||||
|
||||
export default function ModelItem({ modelName, value, onSelect }) {
|
||||
export default function ModelItem({ modelName, value, model, onSelect, id, chatGptLabel, promptPrefix }) {
|
||||
const dispatch = useDispatch();
|
||||
const { customModel } = useSelector((state) => state.submit);
|
||||
const { initial } = useSelector((state) => state.models);
|
||||
const [isHovering, setIsHovering] = useState(false);
|
||||
@@ -15,8 +18,17 @@ export default function ModelItem({ modelName, value, onSelect }) {
|
||||
const [currentName, setCurrentName] = useState(modelName);
|
||||
const [modelInput, setModelInput] = useState(modelName);
|
||||
const inputRef = useRef(null);
|
||||
const rename = manualSWR(`http://localhost:3080/api/customGpts`, 'post');
|
||||
const deleteCustom = manualSWR(`http://localhost:3080/api/customGpts/delete`, 'post');
|
||||
const rename = manualSWR(`/api/customGpts`, 'post');
|
||||
const deleteCustom = manualSWR(`/api/customGpts/delete`, 'post', (res) => {
|
||||
const fetchedModels = res.data.map((modelItem) => ({
|
||||
...modelItem,
|
||||
name: modelItem.chatGptLabel
|
||||
}));
|
||||
|
||||
dispatch(setModels(fetchedModels));
|
||||
});
|
||||
|
||||
const icon = getIconOfModel({ size: 20, sender: modelName, isCreatedByUser: false, model, chatGptLabel, promptPrefix, error: false, className: "mr-2" });
|
||||
|
||||
if (value === 'chatgptCustom') {
|
||||
return (
|
||||
@@ -25,25 +37,27 @@ export default function ModelItem({ modelName, value, onSelect }) {
|
||||
value={value}
|
||||
className="dark:font-semibold dark:text-gray-100 dark:hover:bg-gray-800"
|
||||
>
|
||||
{icon}
|
||||
{modelName}
|
||||
<sup>$</sup>
|
||||
</DropdownMenuRadioItem>
|
||||
</DialogTrigger>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (initial[value]) {
|
||||
if (initial[value])
|
||||
return (
|
||||
<DropdownMenuRadioItem
|
||||
value={value}
|
||||
className="dark:font-semibold dark:text-gray-100 dark:hover:bg-gray-800"
|
||||
>
|
||||
{icon}
|
||||
{modelName}
|
||||
{value === 'chatgpt' && <sup>$</sup>}
|
||||
</DropdownMenuRadioItem>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
const handleMouseOver = () => {
|
||||
setIsHovering(true);
|
||||
};
|
||||
@@ -77,8 +91,7 @@ export default function ModelItem({ modelName, value, onSelect }) {
|
||||
|
||||
const onDelete = async (e) => {
|
||||
e.preventDefault();
|
||||
await deleteCustom.trigger({ value: currentName.toLowerCase() });
|
||||
// await mutate();
|
||||
await deleteCustom.trigger({ _id: id });
|
||||
onSelect('chatgpt', true);
|
||||
};
|
||||
|
||||
@@ -90,63 +103,66 @@ export default function ModelItem({ modelName, value, onSelect }) {
|
||||
|
||||
const buttonClass = {
|
||||
className:
|
||||
'z-50 rounded-md m-0 text-gray-400 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'
|
||||
'invisible group-hover:visible z-50 rounded-md m-0 text-gray-400 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'
|
||||
};
|
||||
|
||||
const itemClass = {
|
||||
className:
|
||||
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm font-medium outline-none hover:bg-slate-100 data-[disabled]:pointer-events-none data-[disabled]:opacity-50 dark:hover:bg-slate-700 dark:font-semibold dark:text-gray-100 dark:hover:bg-gray-800'
|
||||
'relative flex group cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm font-medium outline-none hover:bg-slate-100 data-[disabled]:pointer-events-none data-[disabled]:opacity-50 dark:hover:bg-slate-700 dark:font-semibold dark:text-gray-100 dark:hover:bg-gray-800'
|
||||
};
|
||||
|
||||
const showButtons = isHovering && !initial[value];
|
||||
|
||||
return (
|
||||
<span
|
||||
value={value}
|
||||
className={itemClass.className}
|
||||
onClick={(e) => {
|
||||
if (isHovering) {
|
||||
return;
|
||||
}
|
||||
onSelect(value, true);
|
||||
}}
|
||||
onMouseOver={handleMouseOver}
|
||||
onMouseOut={handleMouseOut}
|
||||
>
|
||||
{customModel === value && (
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<Circle className="h-2 w-2 fill-current" />
|
||||
</span>
|
||||
)}
|
||||
|
||||
{icon}
|
||||
|
||||
{renaming === true ? (
|
||||
<input
|
||||
ref={inputRef}
|
||||
key={id}
|
||||
type="text"
|
||||
className="pointer-events-auto z-50 m-0 mr-2 w-3/4 border border-blue-500 bg-transparent p-0 text-sm leading-tight outline-none"
|
||||
value={modelInput}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onChange={(e) => setModelInput(e.target.value)}
|
||||
onBlur={onRename}
|
||||
// onBlur={onRename}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
) : (
|
||||
modelInput
|
||||
<div className=" overflow-hidden">{modelInput}</div>
|
||||
)}
|
||||
|
||||
{value === 'chatgpt' && <sup>$</sup>}
|
||||
{showButtons && (
|
||||
<>
|
||||
<RenameButton
|
||||
twcss={`ml-auto mr-2 ${buttonClass.className}`}
|
||||
onRename={onRename}
|
||||
renaming={renaming}
|
||||
renameHandler={renameHandler}
|
||||
/>
|
||||
<button
|
||||
{...buttonClass}
|
||||
onClick={onDelete}
|
||||
>
|
||||
<TrashIcon />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<RenameButton
|
||||
twcss={`ml-auto mr-2 ${buttonClass.className}`}
|
||||
onRename={onRename}
|
||||
renaming={renaming}
|
||||
onMouseOver={handleMouseOver}
|
||||
onMouseOut={handleMouseOut}
|
||||
renameHandler={renameHandler}
|
||||
/>
|
||||
<button
|
||||
{...buttonClass}
|
||||
onMouseOver={handleMouseOver}
|
||||
onMouseOut={handleMouseOut}
|
||||
onClick={onDelete}
|
||||
>
|
||||
<TrashIcon />
|
||||
</button>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,14 +1,22 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import axios from 'axios';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import { setModel, setDisabled, setCustomGpt, setCustomModel } from '~/store/submitSlice';
|
||||
import { setConversation } from '~/store/convoSlice';
|
||||
import {
|
||||
setSubmission,
|
||||
setModel,
|
||||
setDisabled,
|
||||
setCustomGpt,
|
||||
setCustomModel
|
||||
} from '~/store/submitSlice';
|
||||
import { setNewConvo } from '~/store/convoSlice';
|
||||
import ModelDialog from './ModelDialog';
|
||||
import MenuItems from './MenuItems';
|
||||
import manualSWR from '~/utils/fetchers';
|
||||
import { setModels } from '~/store/modelSlice';
|
||||
import GPTIcon from '../svg/GPTIcon';
|
||||
import BingIcon from '../svg/BingIcon';
|
||||
import { swr } from '~/utils/fetchers';
|
||||
import { setModels, setInitial } from '~/store/modelSlice';
|
||||
import { setMessages } from '~/store/messageSlice';
|
||||
import { setText } from '~/store/textSlice';
|
||||
import { Button } from '../ui/Button.tsx';
|
||||
import { getIconOfModel } from '../../utils';
|
||||
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -25,68 +33,108 @@ export default function ModelMenu() {
|
||||
const dispatch = useDispatch();
|
||||
const [modelSave, setModelSave] = useState(false);
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const { model, customModel } = useSelector((state) => state.submit);
|
||||
const { model, customModel, promptPrefix, chatGptLabel } = useSelector((state) => state.submit);
|
||||
const { models, modelMap, initial } = useSelector((state) => state.models);
|
||||
const { trigger } = manualSWR(`http://localhost:3080/api/customGpts`, 'get', (res) => {
|
||||
const { data, isLoading, mutate } = swr(`/api/customGpts`, (res) => {
|
||||
const fetchedModels = res.map((modelItem) => ({
|
||||
...modelItem,
|
||||
name: modelItem.chatGptLabel
|
||||
name: modelItem.chatGptLabel,
|
||||
model: 'chatgptCustom'
|
||||
}));
|
||||
|
||||
dispatch(setModels(fetchedModels));
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
trigger();
|
||||
const lastSelected = JSON.parse(localStorage.getItem('model'));
|
||||
if (lastSelected && lastSelected !== 'chatgptCustom' && initial[lastSelected]) {
|
||||
dispatch(setModel(lastSelected));
|
||||
mutate();
|
||||
try {
|
||||
const lastSelected = JSON.parse(localStorage.getItem('model'));
|
||||
|
||||
if (lastSelected === 'chatgptCustom') {
|
||||
return;
|
||||
} else if (initial[lastSelected]) {
|
||||
dispatch(setModel(lastSelected));
|
||||
}
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
axios.get('/api/models', {
|
||||
timeout: 1000,
|
||||
withCredentials: true
|
||||
}).then((res) => {
|
||||
return res.data
|
||||
}).then((data) => {
|
||||
const initial = {chatgpt: data?.hasOpenAI, chatgptCustom: data?.hasOpenAI, bingai: data?.hasBing, sydney: data?.hasBing, chatgptBrowser: data?.hasChatGpt}
|
||||
dispatch(setInitial(initial))
|
||||
// TODO, auto reset default model
|
||||
if (data?.hasOpenAI) {
|
||||
dispatch(setModel('chatgpt'));
|
||||
dispatch(setDisabled(false));
|
||||
dispatch(setCustomModel(null));
|
||||
dispatch(setCustomGpt({ chatGptLabel: null, promptPrefix: null }));
|
||||
} else if (data?.hasBing) {
|
||||
dispatch(setModel('bingai'));
|
||||
dispatch(setDisabled(false));
|
||||
dispatch(setCustomModel(null));
|
||||
dispatch(setCustomGpt({ chatGptLabel: null, promptPrefix: null }));
|
||||
} else if (data?.hasChatGpt) {
|
||||
dispatch(setModel('chatgptBrowser'));
|
||||
dispatch(setDisabled(false));
|
||||
dispatch(setCustomModel(null));
|
||||
dispatch(setCustomGpt({ chatGptLabel: null, promptPrefix: null }));
|
||||
} else {
|
||||
dispatch(setDisabled(true));
|
||||
}
|
||||
}).catch((error) => {
|
||||
console.error(error)
|
||||
console.log('Not login!')
|
||||
window.location.href = "/auth/login";
|
||||
})
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem('model', JSON.stringify(model));
|
||||
}, [model]);
|
||||
|
||||
const onChange = (value, custom = false) => {
|
||||
const filteredModels = models.filter(({model, _id }) => initial[model] );
|
||||
|
||||
const onChange = (value) => {
|
||||
if (!value) {
|
||||
return;
|
||||
} else if (value === model) {
|
||||
return;
|
||||
} else if (value === 'chatgptCustom') {
|
||||
// dispatch(setMessages([]));
|
||||
// return;
|
||||
} else if (initial[value]) {
|
||||
dispatch(setModel(value));
|
||||
dispatch(setDisabled(false));
|
||||
dispatch(setCustomModel(null));
|
||||
if (custom) {
|
||||
trigger();
|
||||
}
|
||||
dispatch(setCustomGpt({ chatGptLabel: null, promptPrefix: null }));
|
||||
} else if (!initial[value]) {
|
||||
const chatGptLabel = modelMap[value]?.chatGptLabel;
|
||||
const promptPrefix = modelMap[value]?.promptPrefix;
|
||||
dispatch(setCustomGpt({ chatGptLabel, promptPrefix }));
|
||||
dispatch(setModel('chatgptCustom'));
|
||||
dispatch(setCustomModel(value));
|
||||
if (custom) {
|
||||
setMenuOpen((prevOpen) => !prevOpen);
|
||||
}
|
||||
setMenuOpen(false);
|
||||
} else if (!modelMap[value]) {
|
||||
dispatch(setCustomModel(null));
|
||||
}
|
||||
|
||||
// Set new conversation
|
||||
dispatch(
|
||||
setConversation({
|
||||
title: 'New Chat',
|
||||
error: false,
|
||||
conversationId: null,
|
||||
parentMessageId: null
|
||||
})
|
||||
);
|
||||
dispatch(setText(''));
|
||||
dispatch(setMessages([]));
|
||||
dispatch(setNewConvo());
|
||||
dispatch(setSubmission({}));
|
||||
};
|
||||
|
||||
const onOpenChange = (open) => {
|
||||
mutate();
|
||||
if (!open) {
|
||||
setModelSave(false);
|
||||
}
|
||||
@@ -126,8 +174,9 @@ export default function ModelMenu() {
|
||||
'dark:disabled:hover:bg-transparent'
|
||||
];
|
||||
|
||||
const isBing = model === 'bingai' || model === 'sydney';
|
||||
const colorProps = model === 'chatgpt' ? chatgptColorProps : defaultColorProps;
|
||||
const icon = model === 'bingai' ? <BingIcon button={true} /> : <GPTIcon button={true} />;
|
||||
const icon = getIconOfModel({ size: 32, sender: chatGptLabel || model, isCreatedByUser: false, model, chatGptLabel, promptPrefix, error: false, button: true});
|
||||
|
||||
return (
|
||||
<Dialog onOpenChange={onOpenChange}>
|
||||
@@ -139,14 +188,14 @@ export default function ModelMenu() {
|
||||
<Button
|
||||
variant="outline"
|
||||
// style={{backgroundColor: 'rgb(16, 163, 127)'}}
|
||||
className={`absolute bottom-0.5 rounded-md border-0 p-1 pl-2 outline-none ${colorProps.join(
|
||||
className={`absolute top-[0.25px] items-center mb-0 rounded-md border-0 p-1 ml-1 md:ml-0 outline-none ${colorProps.join(
|
||||
' '
|
||||
)} focus:ring-0 focus:ring-offset-0 disabled:bottom-0.5 dark:data-[state=open]:bg-opacity-50 md:bottom-1 md:left-2 md:pl-1 md:disabled:bottom-1`}
|
||||
)} focus:ring-0 focus:ring-offset-0 disabled:top-[0.25px] dark:data-[state=open]:bg-opacity-50 md:top-1 md:left-1 md:pl-1 md:disabled:top-1`}
|
||||
>
|
||||
{icon}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-56 dark:bg-gray-700">
|
||||
<DropdownMenuContent className="w-56 dark:bg-gray-700" onCloseAutoFocus={(event) => event.preventDefault()}>
|
||||
<DropdownMenuLabel className="dark:text-gray-300">Select a Model</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuRadioGroup
|
||||
@@ -154,15 +203,17 @@ export default function ModelMenu() {
|
||||
onValueChange={onChange}
|
||||
className="overflow-y-auto"
|
||||
>
|
||||
<MenuItems
|
||||
models={models}
|
||||
onSelect={onChange}
|
||||
/>
|
||||
{filteredModels.length?
|
||||
<MenuItems
|
||||
models={filteredModels}
|
||||
onSelect={onChange}
|
||||
/>:<DropdownMenuLabel className="dark:text-gray-300">No model available.</DropdownMenuLabel>
|
||||
}
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<ModelDialog
|
||||
mutate={trigger}
|
||||
mutate={mutate}
|
||||
modelMap={modelMap}
|
||||
setModelSave={setModelSave}
|
||||
handleSaveState={handleSaveState}
|
||||
|
||||
@@ -3,24 +3,21 @@ import TrashIcon from '../svg/TrashIcon';
|
||||
import { useSWRConfig } from 'swr';
|
||||
import manualSWR from '~/utils/fetchers';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { setConversation, removeAll } from '~/store/convoSlice';
|
||||
import { setNewConvo, removeAll } from '~/store/convoSlice';
|
||||
import { setMessages } from '~/store/messageSlice';
|
||||
import { setSubmission } from '~/store/submitSlice';
|
||||
import { Dialog, DialogTrigger } from '../ui/Dialog.tsx';
|
||||
import DialogTemplate from '../ui/DialogTemplate';
|
||||
|
||||
export default function ClearConvos() {
|
||||
const dispatch = useDispatch();
|
||||
const { mutate } = useSWRConfig();
|
||||
|
||||
const { trigger } = manualSWR(`http://localhost:3080/api/convos/clear`, 'post', () => {
|
||||
const { trigger } = manualSWR(`/api/convos/clear`, 'post', () => {
|
||||
dispatch(setMessages([]));
|
||||
dispatch(
|
||||
setConversation({
|
||||
error: false,
|
||||
title: 'New chat',
|
||||
conversationId: null,
|
||||
parentMessageId: null
|
||||
})
|
||||
);
|
||||
mutate(`http://localhost:3080/api/convos`);
|
||||
dispatch(setNewConvo());
|
||||
dispatch(setSubmission({}));
|
||||
mutate(`/api/convos`);
|
||||
});
|
||||
|
||||
const clickHandler = () => {
|
||||
@@ -30,12 +27,25 @@ export default function ClearConvos() {
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<a
|
||||
className="flex cursor-pointer items-center gap-3 rounded-md py-3 px-3 text-sm text-white transition-colors duration-200 hover:bg-gray-500/10"
|
||||
onClick={clickHandler}
|
||||
// onClick={clickHandler}
|
||||
>
|
||||
<TrashIcon />
|
||||
Clear conversations
|
||||
</a>
|
||||
</DialogTrigger>
|
||||
<DialogTemplate
|
||||
title="Clear conversations"
|
||||
description="Are you sure you want to clear all conversations? This is irreversible."
|
||||
selection={{
|
||||
selectHandler: clickHandler,
|
||||
selectClasses: 'bg-red-600 hover:bg-red-700 dark:hover:bg-red-800 text-white',
|
||||
selectText: 'Clear',
|
||||
}}
|
||||
/>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
23
client/src/components/Nav/Logout.jsx
Normal file
23
client/src/components/Nav/Logout.jsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import React, { useState, useContext } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import LogOutIcon from '../svg/LogOutIcon';
|
||||
|
||||
|
||||
export default function Logout() {
|
||||
const { user } = useSelector((state) => state.user);
|
||||
|
||||
const clickHandler = () => {
|
||||
window.location.href = "/auth/logout";
|
||||
};
|
||||
|
||||
return (
|
||||
<a
|
||||
className="flex cursor-pointer items-center gap-3 rounded-md py-3 px-3 text-sm text-white transition-colors duration-200 hover:bg-gray-500/10"
|
||||
onClick={clickHandler}
|
||||
>
|
||||
<LogOutIcon />
|
||||
{user?.display || user?.username || 'USER'}
|
||||
<small>Log out</small>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,33 @@
|
||||
import React from 'react';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import { setNewConvo } from '~/store/convoSlice';
|
||||
import { setMessages } from '~/store/messageSlice';
|
||||
import { setSubmission } from '~/store/submitSlice';
|
||||
import { setText } from '~/store/textSlice';
|
||||
|
||||
export default function MobileNav({ setNavVisible }) {
|
||||
const dispatch = useDispatch();
|
||||
const { conversationId, convos, title } = useSelector((state) => state.convo);
|
||||
|
||||
const toggleNavVisible = () => {
|
||||
setNavVisible((prev) => {
|
||||
return !prev
|
||||
})
|
||||
}
|
||||
|
||||
const newConvo = () => {
|
||||
dispatch(setText(''));
|
||||
dispatch(setMessages([]));
|
||||
dispatch(setNewConvo());
|
||||
dispatch(setSubmission({}));
|
||||
}
|
||||
|
||||
export default function MobileNav({ title = 'New Chat' }) {
|
||||
return (
|
||||
<div className="sticky top-0 z-10 flex items-center border-b border-white/20 bg-gray-800 pl-1 pt-1 text-gray-200 sm:pl-3 md:hidden">
|
||||
<div className="fixed top-0 left-0 right-0 z-10 flex items-center border-b border-white/20 bg-gray-800 pl-1 pt-1 text-gray-200 sm:pl-3 md:hidden">
|
||||
<button
|
||||
type="button"
|
||||
className="-ml-0.5 -mt-0.5 inline-flex h-10 w-10 items-center justify-center rounded-md hover:text-gray-900 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-white dark:hover:text-white"
|
||||
onClick={toggleNavVisible}
|
||||
>
|
||||
<span className="sr-only">Open sidebar</span>
|
||||
<svg
|
||||
@@ -40,10 +62,11 @@ export default function MobileNav({ title = 'New Chat' }) {
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<h1 className="flex-1 text-center text-base font-normal">{title}</h1>
|
||||
<h1 className="flex-1 text-center text-base font-normal">{title || 'New Chat'}</h1>
|
||||
<button
|
||||
type="button"
|
||||
className="px-3"
|
||||
onClick={newConvo}
|
||||
>
|
||||
<svg
|
||||
stroke="currentColor"
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
import React from 'react';
|
||||
import NavLink from './NavLink';
|
||||
import LogOutIcon from '../svg/LogOutIcon';
|
||||
import SearchBar from './SearchBar';
|
||||
import ClearConvos from './ClearConvos';
|
||||
import DarkMode from './DarkMode';
|
||||
import Logout from './Logout';
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
export default function NavLinks() {
|
||||
export default function NavLinks({ fetch, onSearchSuccess, clearSearch }) {
|
||||
const { searchEnabled } = useSelector((state) => state.search);
|
||||
return (
|
||||
<>
|
||||
<ClearConvos />
|
||||
{ !!searchEnabled && <SearchBar fetch={fetch} onSuccess={onSearchSuccess} clearSearch={clearSearch}/>}
|
||||
<DarkMode />
|
||||
<NavLink
|
||||
svg={LogOutIcon}
|
||||
text="Log out"
|
||||
/>
|
||||
<ClearConvos />
|
||||
<Logout />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import React from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { setConversation } from '~/store/convoSlice';
|
||||
import { setNewConvo, refreshConversation } from '~/store/convoSlice';
|
||||
import { setMessages } from '~/store/messageSlice';
|
||||
import { setSubmission, setDisabled } from '~/store/submitSlice';
|
||||
import { setText } from '~/store/textSlice';
|
||||
import { setInputValue, setQuery } from '~/store/searchSlice';
|
||||
|
||||
export default function NewChat() {
|
||||
const dispatch = useDispatch();
|
||||
@@ -10,7 +12,12 @@ export default function NewChat() {
|
||||
const clickHandler = () => {
|
||||
dispatch(setText(''));
|
||||
dispatch(setMessages([]));
|
||||
dispatch(setConversation({ title: 'New Chat', error: false, conversationId: null, parentMessageId: null }));
|
||||
dispatch(setNewConvo());
|
||||
dispatch(refreshConversation());
|
||||
dispatch(setSubmission({}));
|
||||
dispatch(setDisabled(false));
|
||||
dispatch(setInputValue(''));
|
||||
dispatch(setQuery(''));
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
60
client/src/components/Nav/SearchBar.jsx
Normal file
60
client/src/components/Nav/SearchBar.jsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import { debounce } from 'lodash';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import { Search } from 'lucide-react';
|
||||
import { setInputValue, setQuery } from '~/store/searchSlice';
|
||||
|
||||
export default function SearchBar({ fetch, clearSearch }) {
|
||||
const dispatch = useDispatch();
|
||||
const { inputValue } = useSelector((state) => state.search);
|
||||
// const [inputValue, setInputValue] = useState('');
|
||||
|
||||
const debouncedChangeHandler = useCallback(
|
||||
debounce((q) => {
|
||||
dispatch(setQuery(q));
|
||||
if (q.length > 0) {
|
||||
fetch(q, 1);
|
||||
}
|
||||
}, 750),
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
const handleKeyUp = (e) => {
|
||||
const { value } = e.target;
|
||||
if (e.keyCode === 8 && value === '') {
|
||||
// Value after clearing input: ""
|
||||
console.log(`Value after clearing input: "${value}"`);
|
||||
dispatch(setQuery(''));
|
||||
clearSearch();
|
||||
}
|
||||
};
|
||||
|
||||
const changeHandler = (e) => {
|
||||
let q = e.target.value;
|
||||
dispatch(setInputValue(q));
|
||||
q = q.trim();
|
||||
|
||||
if (q === '') {
|
||||
dispatch(setQuery(''));
|
||||
clearSearch();
|
||||
} else {
|
||||
debouncedChangeHandler(q);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex cursor-pointer items-center gap-3 rounded-md py-3 px-3 text-sm text-white transition-colors duration-200 hover:bg-gray-500/10">
|
||||
{<Search className="h-4 w-4" />}
|
||||
<input
|
||||
// ref={inputRef}
|
||||
type="text"
|
||||
className="m-0 mr-0 w-full border-none bg-transparent p-0 text-sm leading-tight outline-none"
|
||||
value={inputValue}
|
||||
onChange={changeHandler}
|
||||
placeholder="Search messages"
|
||||
onKeyUp={handleKeyUp}
|
||||
// onBlur={onRename}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,40 +1,107 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import _ from 'lodash';
|
||||
import NewChat from './NewChat';
|
||||
import Spinner from '../svg/Spinner';
|
||||
import Pages from '../Conversations/Pages';
|
||||
import Conversations from '../Conversations';
|
||||
import NavLinks from './NavLinks';
|
||||
import useDidMountEffect from '~/hooks/useDidMountEffect';
|
||||
import { swr } from '~/utils/fetchers';
|
||||
import { searchFetcher, swr } from '~/utils/fetchers';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { incrementPage, setConvos } from '~/store/convoSlice';
|
||||
import { setConvos, setNewConvo, refreshConversation } from '~/store/convoSlice';
|
||||
import { setMessages } from '~/store/messageSlice';
|
||||
import { setDisabled } from '~/store/submitSlice';
|
||||
|
||||
export default function Nav() {
|
||||
export default function Nav({ navVisible, setNavVisible }) {
|
||||
const dispatch = useDispatch();
|
||||
const [isHovering, setIsHovering] = useState(false);
|
||||
const { conversationId, convos, pageNumber } = useSelector((state) => state.convo);
|
||||
const onSuccess = (data) => {
|
||||
dispatch(setConvos(data));
|
||||
const [isFetching, setIsFetching] = useState(false);
|
||||
const [pages, setPages] = useState(1);
|
||||
const [pageNumber, setPage] = useState(1);
|
||||
const { search, query } = useSelector((state) => state.search);
|
||||
const { conversationId, convos, refreshConvoHint } = useSelector((state) => state.convo);
|
||||
|
||||
const onSuccess = (data, searchFetch = false) => {
|
||||
if (search) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { conversations, pages } = data;
|
||||
if (pageNumber > pages) {
|
||||
setPage(pages);
|
||||
} else {
|
||||
dispatch(setConvos({ convos: conversations, searchFetch }));
|
||||
setPages(pages);
|
||||
}
|
||||
};
|
||||
|
||||
const { data, isLoading, mutate } = swr(
|
||||
`http://localhost:3080/api/convos?pageNumber=${pageNumber}`,
|
||||
onSuccess
|
||||
);
|
||||
const onSearchSuccess = (data, expectedPage) => {
|
||||
const res = data;
|
||||
dispatch(setConvos({ convos: res.conversations, searchFetch: true }));
|
||||
if (expectedPage) {
|
||||
setPage(expectedPage);
|
||||
}
|
||||
setPage(res.pageNumber);
|
||||
setPages(res.pages);
|
||||
setIsFetching(false);
|
||||
if (res.messages?.length > 0) {
|
||||
dispatch(setMessages(res.messages));
|
||||
dispatch(setDisabled(true));
|
||||
}
|
||||
};
|
||||
|
||||
const fetch = useCallback(_.partialRight(searchFetcher.bind(null, () => setIsFetching(true)), onSearchSuccess), [dispatch]);
|
||||
|
||||
const clearSearch = () => {
|
||||
setPage(1);
|
||||
dispatch(refreshConversation());
|
||||
if (!conversationId) {
|
||||
dispatch(setNewConvo());
|
||||
dispatch(setMessages([]));
|
||||
}
|
||||
dispatch(setDisabled(false));
|
||||
};
|
||||
|
||||
const { data, isLoading, mutate } = swr(`/api/convos?pageNumber=${pageNumber}`, onSuccess, {
|
||||
revalidateOnMount: false,
|
||||
});
|
||||
|
||||
const containerRef = useRef(null);
|
||||
const scrollPositionRef = useRef(null);
|
||||
|
||||
const showMore = async (increment = true) => {
|
||||
if (increment) {
|
||||
const container = containerRef.current;
|
||||
if (container) {
|
||||
scrollPositionRef.current = container.scrollTop;
|
||||
}
|
||||
dispatch(incrementPage());
|
||||
const moveToTop = () => {
|
||||
const container = containerRef.current;
|
||||
if (container) {
|
||||
scrollPositionRef.current = container.scrollTop;
|
||||
}
|
||||
await mutate();
|
||||
};
|
||||
|
||||
useDidMountEffect(() => mutate(), [conversationId]);
|
||||
const nextPage = async () => {
|
||||
moveToTop();
|
||||
|
||||
if (!search) {
|
||||
setPage((prev) => prev + 1);
|
||||
await mutate();
|
||||
} else {
|
||||
await fetch(query, +pageNumber + 1);
|
||||
}
|
||||
};
|
||||
|
||||
const previousPage = async () => {
|
||||
moveToTop();
|
||||
|
||||
if (!search) {
|
||||
setPage((prev) => prev - 1);
|
||||
await mutate();
|
||||
} else {
|
||||
await fetch(query, +pageNumber - 1);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!search) {
|
||||
mutate();
|
||||
}
|
||||
}, [pageNumber, conversationId, refreshConvoHint]);
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
@@ -47,42 +114,105 @@ export default function Nav() {
|
||||
}
|
||||
}, [data]);
|
||||
|
||||
useEffect(() => {
|
||||
setNavVisible(false);
|
||||
}, [conversationId]);
|
||||
|
||||
const toggleNavVisible = () => {
|
||||
setNavVisible((prev) => {
|
||||
return !prev;
|
||||
});
|
||||
};
|
||||
|
||||
const containerClasses =
|
||||
isLoading && pageNumber === 1
|
||||
? 'flex flex-col gap-2 text-gray-100 text-sm h-full justify-center items-center'
|
||||
: 'flex flex-col gap-2 text-gray-100 text-sm';
|
||||
|
||||
return (
|
||||
<div className="dark hidden bg-gray-900 md:fixed md:inset-y-0 md:flex md:w-[260px] md:flex-col">
|
||||
<div className="flex h-full min-h-0 flex-col ">
|
||||
<div className="scrollbar-trigger flex h-full w-full flex-1 items-start border-white/20">
|
||||
<nav className="flex h-full flex-1 flex-col space-y-1 p-2">
|
||||
<NewChat />
|
||||
<div
|
||||
className={`-mr-2 flex-1 flex-col overflow-y-auto ${
|
||||
isHovering ? '' : 'scrollbar-transparent'
|
||||
} border-b border-white/20`}
|
||||
onMouseEnter={() => setIsHovering(true)}
|
||||
onMouseLeave={() => setIsHovering(false)}
|
||||
ref={containerRef}
|
||||
>
|
||||
<div className={containerClasses}>
|
||||
{isLoading && pageNumber === 1 ? (
|
||||
<Spinner />
|
||||
) : (
|
||||
<Conversations
|
||||
conversations={convos}
|
||||
conversationId={conversationId}
|
||||
showMore={showMore}
|
||||
<>
|
||||
<div
|
||||
className={
|
||||
'nav dark bg-gray-900 md:fixed md:inset-y-0 md:flex md:w-[260px] md:flex-col' +
|
||||
(navVisible ? ' active' : '')
|
||||
}
|
||||
>
|
||||
<div className="flex h-full min-h-0 flex-col ">
|
||||
<div className="scrollbar-trigger flex h-full w-full flex-1 items-start border-white/20">
|
||||
<nav className="flex h-full flex-1 flex-col space-y-1 p-2">
|
||||
<NewChat />
|
||||
<div
|
||||
className={`flex-1 flex-col overflow-y-auto ${
|
||||
isHovering ? '' : 'scrollbar-transparent'
|
||||
} border-b border-white/20`}
|
||||
onMouseEnter={() => setIsHovering(true)}
|
||||
onMouseLeave={() => setIsHovering(false)}
|
||||
ref={containerRef}
|
||||
>
|
||||
<div className={containerClasses}>
|
||||
{/* {(isLoading && pageNumber === 1) ? ( */}
|
||||
{(isLoading && pageNumber === 1) || (isFetching) ? (
|
||||
<Spinner />
|
||||
) : (
|
||||
<Conversations
|
||||
conversations={convos}
|
||||
conversationId={conversationId}
|
||||
moveToTop={moveToTop}
|
||||
/>
|
||||
)}
|
||||
<Pages
|
||||
pageNumber={pageNumber}
|
||||
pages={pages}
|
||||
nextPage={nextPage}
|
||||
previousPage={previousPage}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<NavLinks />
|
||||
</nav>
|
||||
<NavLinks
|
||||
fetch={fetch}
|
||||
onSearchSuccess={onSearchSuccess}
|
||||
clearSearch={clearSearch}
|
||||
/>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="nav-close-button -ml-0.5 -mt-0.5 inline-flex h-10 w-10 items-center justify-center rounded-md text-white hover:text-gray-900 hover:text-white focus:outline-none focus:ring-white"
|
||||
onClick={toggleNavVisible}
|
||||
>
|
||||
<span className="sr-only">Open sidebar</span>
|
||||
<svg
|
||||
stroke="currentColor"
|
||||
fill="none"
|
||||
strokeWidth="1.5"
|
||||
viewBox="0 0 24 24"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="h-6 w-6"
|
||||
height="1em"
|
||||
width="1em"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<line
|
||||
x1="3"
|
||||
y1="6"
|
||||
x2="15"
|
||||
y2="18"
|
||||
/>
|
||||
<line
|
||||
x1="3"
|
||||
y1="18"
|
||||
x2="15"
|
||||
y2="6"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={'nav-mask' + (navVisible ? ' active' : '')}
|
||||
onClick={toggleNavVisible}
|
||||
></div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import React from 'react';
|
||||
|
||||
export default function BingIcon() {
|
||||
export default function BingIcon({ size=25 }) {
|
||||
return (
|
||||
<svg
|
||||
width="25"
|
||||
height="25"
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 56 56"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user