Compare commits

...

125 Commits

Author SHA1 Message Date
Danny Avila
c6fb3018e7 Merge pull request #117 from danny-avila/search-final
Search final
2023-03-23 13:37:55 -04:00
Danny Avila
95cf27ee3e edit env example 2023-03-23 13:30:55 -04:00
Danny Avila
7afe09fa02 chore: clear timeouts 2023-03-23 13:16:07 -04:00
Danny Avila
bff33c79b3 merge latest pr from main 2023-03-23 13:04:40 -04:00
Danny Avila
f73936e5f4 Merge branch 'main' into search-final 2023-03-23 13:02:52 -04:00
Danny Avila
2b8d37c38f edit env example 2023-03-23 12:45:42 -04:00
Danny Avila
ca3da2505a edit env example 2023-03-23 11:49:54 -04:00
Danny Avila
d3046dca07 Merge pull request #115 from HyunggyuJang/sydney/branching
Enable branching (edit message) for Sydney
2023-03-23 11:38:44 -04:00
Danny Avila
25fd39c2b9 Update README.md
organize readme for readability
2023-03-23 11:35:23 -04:00
Daniel Avila
ab8724b568 edit docker-compose for meilisearch 2023-03-22 22:35:25 -04:00
Hyunggyu Jang
c240d14864 Enable branching (edit message) for Sydney 2023-03-23 11:27:14 +09:00
Daniel Avila
1d464fdcfa reduce noisy meili errors if not set 2023-03-22 21:23:01 -04:00
Daniel Avila
aacc292522 Merge branch 'main' into search-final 2023-03-22 20:57:20 -04:00
Danny Avila
350a1bbae0 Update README.md 2023-03-22 20:48:47 -04:00
Danny Avila
90b74aff2e Merge pull request #104 from HyunggyuJang/refactor/docker
Refactor: merge docker setup file into one dockerfile & one docker-compose.yml
2023-03-22 20:13:10 -04:00
Daniel Avila
37f36ec44a chore: update meilisearch compose 2023-03-22 20:12:38 -04:00
Daniel Avila
71b7eaa3f5 chore: update env example and add browser client config 2023-03-22 20:09:52 -04:00
Daniel Avila
719413f87a feat: syncs across document deletions 2023-03-22 19:53:09 -04:00
Daniel Avila
97634865eb feat: syncs across document deletions 2023-03-22 19:52:38 -04:00
Daniel Avila
1dbfb0dab7 minor styling changes, cache queried messages on server 2023-03-22 18:26:29 -04:00
Daniel Avila
0a671849b5 feat: clearing convos requires confirmation 2023-03-22 17:51:51 -04:00
Daniel Avila
655e7ce6d6 chore: improve meili error handling 2023-03-22 17:15:32 -04:00
Danny Avila
68979015c1 markdown styling changes in progress 2023-03-22 16:31:57 -04:00
Danny Avila
8f58c95452 feat: main styling/ui/ux final changes 2023-03-22 16:06:11 -04:00
Danny Avila
67161c983f chore: meilisearch setup config 2023-03-22 10:23:55 -04:00
Danny Avila
73449d9ec6 feat: build tree by convoId 2023-03-22 10:23:36 -04:00
HyunggyuJang
f5d102b7bd Update docker-compose.yml 2023-03-22 23:20:03 +09:00
Hyunggyu Jang
40ed6fa9ec Provide nginx docker build recipe 2023-03-22 22:59:29 +09:00
Danny Avila
5164cf46ac chore: error handling for complete omission of env var 2023-03-22 09:38:38 -04:00
Daniel Avila
277685c218 search: updating search endpoint (wip) 2023-03-22 01:34:36 -04:00
Daniel Avila
e25aa74d7b search: correctly register/export schema/models, also made IIFE 2023-03-22 01:33:49 -04:00
Daniel Avila
47a6cfcafd chore: error controller (wip) 2023-03-22 01:32:55 -04:00
Daniel Avila
83a96706b4 chore: add prettier 2023-03-22 01:32:30 -04:00
Daniel Avila
75be4d9722 search: helper fn for invalid convoId strings 2023-03-22 01:31:49 -04:00
Daniel Avila
a5cf2f9148 search: correctly register/export schema/models for mongoMeili 2023-03-22 01:31:01 -04:00
Daniel Avila
8be19f9982 search: sync on offset between meili and mongo 2023-03-22 01:30:04 -04:00
Hyunggyu Jang
36f3d37ecc Remove nginx setting 2023-03-22 10:50:59 +09:00
Hyunggyu Jang
c233cc0d5c Move Dockerfiles into one toplevel Dockerfile 2023-03-22 10:50:44 +09:00
Daniel Avila
194051e424 feat: api will plugin mongoMeili without a valid connection 2023-03-21 20:06:27 -04:00
Daniel Avila
94c0fbb525 feat: api will disable search if no meilisearch connection 2023-03-21 19:44:31 -04:00
Daniel Avila
97a6cd801b feat: simple api call to enable search 2023-03-21 19:31:57 -04:00
Danny Avila
1041146fcb Merge pull request #108 from danny-avila/upgrade-fix
fix: chatgpt now using latest
2023-03-21 17:41:05 -04:00
Danny Avila
e531a17e0f fix: chatgpt now using latest 2023-03-21 16:32:32 -04:00
Danny Avila
30a7a80bfc Merge pull request #107 from danny-avila/chores
Chores
2023-03-21 14:27:00 -04:00
Danny Avila
67f8374c9e chore: eslint rules and blinker update in text handling 2023-03-21 13:41:31 -04:00
Danny Avila
0cc4aea204 chore: reorg. content files, add blinking cursor 2023-03-21 09:46:08 -04:00
Danny Avila
04796824d5 chore: init eslint 2023-03-21 08:48:35 -04:00
Danny Avila
9020239e1f Merge pull request #103 from HyunggyuJang/use-lock-file
Use npm ci rather than install to be consistent with lock file
2023-03-21 08:31:09 -04:00
Hyunggyu Jang
0a12b47760 Use npm ci rather than install to be consistent with lock file 2023-03-21 12:23:25 +09:00
Danny Avila
9358a4fdb5 Merge pull request #102 from danny-avila/bing-hotfix
bing hotfix, latest api, uses sydney
2023-03-20 17:16:45 -04:00
Daniel Avila
7d796f2c3e bing hotfix, latest api, uses sydney 2023-03-20 17:02:37 -04:00
Danny Avila
0a1651f6a1 Update README.md 2023-03-20 04:44:42 -04:00
Daniel Avila
d13315c45b chore: comment out auth route 2023-03-20 02:58:41 -04:00
Daniel Avila
0b75d5d6fe chore: another hotfix patch on getConvoTitle 2023-03-20 02:30:08 -04:00
Wentao Lyu
39819b744c doc: add magic of generate auth code 2023-03-20 02:22:04 -04:00
Daniel Avila
28c8f066d9 chore: add latest api as dep with alias 2023-03-20 02:18:03 -04:00
Daniel Avila
c85602b93b chore: Replace hard coded message ID with unique one 2023-03-20 01:51:07 -04:00
Daniel Avila
08c91871c7 chore: hotfix: browser will not leave empty convo 2023-03-20 01:42:06 -04:00
Daniel Avila
80ca3bc375 chore: hotfix for browser client 2023-03-20 01:35:02 -04:00
HyunggyuJang
0405206438 Remove crypto from client side
Unless, if we tries to access the client side page from http protocol, crypto.randomUUID() isn't available.
2023-03-20 00:58:17 -04:00
Daniel Avila
0af8f6a699 chore: fix broken browser client 2023-03-20 00:51:56 -04:00
Daniel Avila
b0936fa322 chore: unplug meilisearch, add leading option to throttle 2023-03-20 00:48:16 -04:00
Daniel Avila
4cd0ff2682 fix: throttle scroll to bottom 2023-03-19 11:45:03 -04:00
Daniel Avila
4ce60537ca search result styling changes 2023-03-19 11:25:12 -04:00
Daniel Avila
0b47218cd5 markdown library change 2023-03-19 01:14:19 -04:00
Daniel Avila
d56aa2edef loads up to 20 messages, debugging markdown issue 2023-03-18 23:18:36 -04:00
Daniel Avila
4e6168d8fa setup message population on search 2023-03-18 18:40:53 -04:00
Daniel Avila
4197a92609 feat: search working as expected 2023-03-18 17:49:24 -04:00
Daniel Avila
da42d6272a add loading state for fetching 2023-03-18 15:59:59 -04:00
Daniel Avila
b97594c000 fix: conflicting fetch with /api/convos 2023-03-18 14:28:10 -04:00
Daniel Avila
0f54ffd8b4 feat: search bar working, still in progress 2023-03-18 01:40:49 -04:00
Daniel Avila
610cba4a60 backend logic drafted, moving to frontend 2023-03-17 22:20:36 -04:00
Daniel Avila
4f5ee8b198 move db functions 2023-03-17 19:58:13 -04:00
Daniel Avila
586c162404 merge commit 2023-03-17 19:40:44 -04:00
Danny Avila
1308ef1394 Merge pull request #88 from wtlyu/feat-clean-after-regenerate-pr
Feat clean after regenerate pr
2023-03-17 15:31:32 -04:00
Danny Avila
1513c27f7d adjust: wording and placeholder text size 2023-03-17 14:48:21 -04:00
Wentao Lyu
0ff3bbb28f clean up 2023-03-18 02:15:24 +08:00
Wentao Lyu
7987c0100c fix: add crypto as deps. [need confirm] 2023-03-18 02:04:51 +08:00
Wentao Lyu
e8611a1d07 fix: don't reset isSubmitting in useEffect of submission 2023-03-18 01:54:06 +08:00
Wentao Lyu
ce78123369 fix: allow delete of last char while submitting 2023-03-18 01:24:35 +08:00
Wentao Lyu
a90db1f1a4 fix: add model to customGpts
fix: filter models by model only
2023-03-18 01:12:45 +08:00
Wentao Lyu
a213868b17 fix: set isSubmitting with messages together
style: some notification
2023-03-18 01:12:45 +08:00
Wentao Lyu
6b2a2bb858 feat: save cancelled flag in message 2023-03-18 01:11:44 +08:00
Wentao Lyu
fea3afa740 fix: missing import 2023-03-18 01:10:20 +08:00
Wentao Lyu
7372b37fe6 style: hide footer in mobile mode
style: avoid ui overflow in mobile mode
2023-03-18 01:09:59 +08:00
Danny Avila
e11ce141d7 Merge pull request #86 from danny-avila/fix-scroll
fix: fix weird scrolling behavior on last message
2023-03-17 12:38:15 -04:00
Danny Avila
46fbd3b66a fix: fix weird scrolling behavior on last message 2023-03-17 12:34:54 -04:00
Danny Avila
ce3f03267a Merge pull request #80 from wtlyu/feat-regenerate-and-cancel
Feat regenerate and cancel
2023-03-17 10:40:45 -04:00
Danny Avila
9a2392e4d5 fix: mobile styling after regen buttons 2023-03-17 22:20:23 +08:00
Danny Avila
1eab4d240d Merge pull request #85 from danny-avila/bing-refusal
Bing refusal
2023-03-17 09:14:40 -04:00
Danny Avila
5568a60174 handle bing message refusal 2023-03-17 09:11:45 -04:00
Danny Avila
ea4180f22a remove detect code (not always accurate) 2023-03-17 09:11:31 -04:00
Daniel Avila
6d2f3361d0 feat: search in progress 2023-03-16 21:20:40 -04:00
Daniel Avila
9995a159aa feat: reorganize api files, add mongoMeili 2023-03-16 19:38:16 -04:00
Daniel Avila
854f1c3572 feat: search, refactoring messages model 2023-03-16 17:20:26 -04:00
Danny Avila
dcc13daf67 testing: mongo meilisearch 2023-03-16 16:22:08 -04:00
Wentao Lyu
2310bab348 revert: dont overwrite the mobile input panel color 2023-03-17 03:54:15 +08:00
Wentao Lyu
d64edfdc7d feat: add chrome title color 2023-03-17 03:50:34 +08:00
Wentao Lyu
a8c53f1f0d mobile style, for input panel and regenerate buttons 2023-03-17 03:50:34 +08:00
Wentao Lyu
ef9f1ee1cf feat: cancellable api request 2023-03-17 03:50:34 +08:00
Wentao Lyu
66ad54168a feat: regenerate for bingai 2023-03-17 03:50:34 +08:00
Wentao Lyu
0891566d1e feat: add regenerate to all response message as official 2023-03-17 03:49:59 +08:00
Wentao Lyu
e3b0cb7db7 feat: show model at the top of conversation messages. 2023-03-17 03:48:45 +08:00
Danny Avila
c27554ed2e fix: allow customs in model filter 2023-03-16 15:05:23 -04:00
Danny Avila
87f793f1c4 Merge pull request #79 from danny-avila/improve-titles
fix: add deterministic titling
2023-03-16 15:03:04 -04:00
Danny Avila
4078c5283b fix: add deterministic titling 2023-03-16 15:02:40 -04:00
Danny Avila
5cac7e48f0 Merge pull request #74 from wtlyu/feat-model-based-on-key
feat: show model based on configured Keys
2023-03-16 15:00:23 -04:00
Danny Avila
7a08c77850 Update README.md 2023-03-16 14:11:24 -04:00
Danny Avila
1fe9e29187 Merge branch 'master' into feat-model-based-on-key 2023-03-16 13:39:41 -04:00
Danny Avila
ba8692dbe4 Merge pull request #59 from wtlyu/feat-usersys
Feat usersys
2023-03-16 13:35:03 -04:00
Danny Avila
d30b406c4c Merge pull request #4 from wtlyu/revisions
Revisions
2023-03-16 13:34:36 -04:00
Danny Avila
915cda70ef fix: user:user redundancies 2023-03-16 13:32:04 -04:00
Danny Avila
df19595c5b fix: icon and mobile textarea styling 2023-03-16 13:30:10 -04:00
Danny Avila
867b3073d4 fix: icon and mobile textarea styling 2023-03-16 13:29:13 -04:00
Danny Avila
e4e28dbbe2 Update README.md 2023-03-16 10:57:44 -04:00
Danny Avila
cdbc0e21e7 Update README.md 2023-03-16 10:57:18 -04:00
Wentao Lyu
b6f7f95709 feat: set default model once model list read. 2023-03-16 21:12:33 +08:00
Wentao Lyu
131af50034 feat: show model based on configured Keys 2023-03-16 14:44:14 +08:00
Wentao Lyu
23c050b54e style: mobile style of landing 2023-03-16 14:07:48 +08:00
Wentao Lyu
7442294c41 fix: mobile sizing style of icon 2023-03-16 14:05:12 +08:00
Wentao Lyu
a47dbe6262 feat: use same icon style in TextChat 2023-03-16 13:45:46 +08:00
Wentao Lyu
aabb19656e feat: combine customgpt to user 2023-03-16 13:30:20 +08:00
Wentao Lyu
b0284b6974 sync updates and merge with feat-resubmit 2023-03-16 13:20:54 +08:00
Wentao Lyu
62d88380e0 feat: add sample multi-user support
feat: update README
2023-03-16 13:20:34 +08:00
Daniel Avila
41f351786f fix: oversight, artifact in bing route 2023-03-15 22:36:16 -04:00
109 changed files with 10443 additions and 1554 deletions

2
.dockerignore Normal file
View File

@@ -0,0 +1,2 @@
**/node_modules
**/.env

2
.gitignore vendored
View File

@@ -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/

35
Dockerfile Normal file
View 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;"]

132
README.md
View File

@@ -8,6 +8,41 @@ https://user-images.githubusercontent.com/110412045/223754183-8b7f45ce-6517-4bd5
## Updates
<details open>
<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>Previous Updates</strong></summary>
<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>
@@ -22,8 +57,6 @@ Many improvements across the board, the biggest is being able to start conversat
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>
<details>
<summary><strong>2023-03-09</strong></summary>
Released v.0.0.2
@@ -40,8 +73,6 @@ Due to increased interest in the repo, I've dockerized the app as of this update
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>
<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.
@@ -84,6 +115,7 @@ Currently, this project is only functional with the `text-davinci-003` model.
- [Docker](#docker)
- [Access Tokens](#access-tokens)
- [Proxy](#proxy)
- [User System](#user-system)
- [Updating](#updating)
- [Use Cases](#use-cases)
- [Origin](#origin)
@@ -98,7 +130,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
@@ -111,17 +146,19 @@ Here are my recently completed and planned features:
- [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))
- [ ] Bing AI Styling (for suggested responses, convo end, etc.) - **In progress**
- [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)
- [ ] Prompt Templates/Search
- [ ] Refactor/clean up code (tech debt)
- [ ] Optional use of local storage for credentials
- [ ] Deploy demo
</details>
### Features
- Response streaming identical to ChatGPT through server-sent events
@@ -135,10 +172,19 @@ Here are my recently completed and planned features:
### 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
@@ -146,6 +192,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)
@@ -159,7 +206,7 @@ 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
- **Run npm** ci 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
@@ -169,16 +216,8 @@ By default, only local machine can access this server. To share within network o
### 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
@@ -235,11 +274,53 @@ set in docker-compose.yml file, under services - api - environment
</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 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
@@ -255,6 +336,8 @@ set in docker-compose.yml file, under services - api - environment
- **ChatGPT Free is down.**
![use case example](./images/use_case.png "GPT is down! Plus is too expensive!")
</details>
## Origin ##
@@ -273,7 +356,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.

View File

@@ -1,2 +0,0 @@
/node_modules
.env

View File

@@ -18,5 +18,47 @@ MONGO_URI="mongodb://127.0.0.1:27017/chatgpt-clone"
# API key configuration.
# Leave blank if you don't want them.
OPENAI_KEY=
CHATGPT_TOKEN=
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://0.0.0.0:7700' # <-- local/remote
MEILI_HOST='http://meilisearch:7700' # <-- docker-compose
# 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='0.0.0.0:7700' # <-- local/remote
MEILI_HTTP_ADDR='meilisearch:7700' # <-- docker-compose
# 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= # <-- no/insecure key 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=FALSE

39
api/.eslintrc.js Normal file
View 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
View 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"
}

View File

@@ -1,16 +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
# Expose the server to 0.0.0.0
ENV HOST=0.0.0.0
# Run the app when the container launches
CMD ["npm", "start"]
# docker build -t node-api .

View File

@@ -1,5 +1,6 @@
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.
@@ -10,7 +11,13 @@ const clientOptions = {
proxy: process.env.PROXY || null,
};
const browserClient = async ({ text, onProgress, convo }) => {
// 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 = {
@@ -18,12 +25,17 @@ const browserClient = async ({ text, onProgress, convo }) => {
};
const client = new ChatGPTBrowserClient(clientOptions, store);
let options = { onProgress };
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;
};

View File

@@ -9,14 +9,14 @@ const clientOptions = {
debug: false
};
const askClient = async ({ text, onProgress, 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 };
let options = { onProgress, abortController };
if (!!convo.parentMessageId && !!convo.conversationId) {
options = { ...options, ...convo };

View File

@@ -9,7 +9,7 @@ const clientOptions = {
debug: false
};
const customClient = async ({ text, onProgress, 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' })
@@ -23,7 +23,7 @@ const customClient = async ({ text, onProgress, convo, promptPrefix, chatGptLabe
const client = new ChatGPTClient(process.env.OPENAI_KEY, clientOptions, store);
let options = { onProgress };
let options = { onProgress, abortController };
if (!!convo.parentMessageId && !!convo.conversationId) {
options = { ...options, ...convo };
}

View File

@@ -1,12 +1,12 @@
const { askClient } = require('./chatgpt-client');
const { browserClient } = require('./chatgpt-browser');
const customClient = require('./chatgpt-custom');
const { askBing } = require('./bingai');
const { askSydney } = require('./sydney');
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 getCitations = require('./getCitations');
const citeText = require('./citeText');
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,

View File

@@ -1,7 +1,7 @@
const { Configuration, OpenAIApi } = require('openai');
const _ = require('lodash');
const proxyEnvToAxiosProxy = (proxyString) => {
const proxyEnvToAxiosProxy = proxyString => {
if (!proxyString) return null;
const regex = /^([^:]+):\/\/(?:([^:@]*):?([^:@]*)@)?([^:]+)(?::(\d+))?/;
@@ -18,30 +18,37 @@ const proxyEnvToAxiosProxy = (proxyString) => {
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: 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 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
};
// console.log('REQUEST', request);
try {
const configuration = new Configuration({
apiKey: process.env.OPENAI_KEY
});
const openai = new OpenAIApi(configuration);
const completion = await openai.createChatCompletion(
{
model: 'gpt-3.5-turbo',
messages: [
{
role: 'system',
content:
'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 using the language the user writes in. 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: "${text}"\n\n${model}: "${JSON.stringify(
response?.text
)}"\n\nTitle: `
}
]
},
{ proxy: proxyEnvToAxiosProxy(process.env.PROXY || null) }
);
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, '');

View File

@@ -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
View 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
View 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;

View File

@@ -8,6 +8,7 @@ const citeText = (res, noLinks = false) => {
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>`);
});
@@ -20,7 +21,8 @@ const citeText = (res, noLinks = false) => {
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>`);
// result = result.replaceAll(citation, `<sup>[${digit}](${sources[digit - 1]}) </sup>`);
});
return result;

View File

@@ -1,5 +1,5 @@
const { ModelOperations } = require('@vscode/vscode-languagedetection');
const languages = require('../utils/languages.js');
const languages = require('./languages.js');
const codeRegex = /(```[\s\S]*?```)/g;
// const languageMatch = /```(\w+)/;
const replaceRegex = /```\w+\n/g;

View 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
View 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
};

View File

@@ -0,0 +1,57 @@
const mergeSort = require('./mergeSort');
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) {
if (!counts[convo.conversationId]) {
counts[convo.conversationId] = 1;
} else {
counts[convo.conversationId]++;
}
if (convo.title) {
// titleMap[convo.conversationId] = convo._formatted.title;
titleMap[convo.conversationId] = 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
View 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' };
}
}
};

View File

@@ -1,60 +1,11 @@
const mongoose = require('mongoose');
const crypto = require('crypto');
// const { Conversation } = require('./plugins');
const Conversation = require('./schema/convoSchema');
const { cleanUpPrimaryKeyValue } = require('../lib/utils/misc');
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 Chat'
},
jailbreakConversationId: {
type: String,
default: null
},
conversationSignature: {
type: String,
default: null
},
clientId: {
type: String
},
invocationId: {
type: String
},
chatGptLabel: {
type: String,
default: null
},
promptPrefix: {
type: String,
default: null
},
model: {
type: String,
required: true
},
suggestions: [{ type: String }],
messages: [{ type: mongoose.Schema.Types.ObjectId, ref: 'Message' }]
},
{ timestamps: true }
);
const Conversation =
mongoose.models.Conversation || mongoose.model('Conversation', convoSchema);
const getConvo = async (conversationId) => {
const getConvo = async (user, conversationId) => {
try {
return await Conversation.findOne({ conversationId }).exec();
return await Conversation.findOne({ user, conversationId }).exec();
} catch (error) {
console.log(error);
return { message: 'Error getting single conversation' };
@@ -62,12 +13,14 @@ const getConvo = async (conversationId) => {
};
module.exports = {
saveConvo: async ({ conversationId, newConversationId, 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;
@@ -82,7 +35,7 @@ module.exports = {
}
return await Conversation.findOneAndUpdate(
{ conversationId },
{ conversationId: conversationId, user },
{ $set: update },
{ new: true, upsert: true }
).exec();
@@ -91,22 +44,25 @@ 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({ createdAt: -1 }).exec(),
getConvosByPage: async (pageNumber = 1, pageSize = 12) => {
getConvosByPage: async (user, pageNumber = 1, pageSize = 12) => {
try {
const totalConvos = (await Conversation.countDocuments()) || 1;
const totalConvos = (await Conversation.countDocuments({ user })) || 1;
const totalPages = Math.ceil(totalConvos / pageSize);
const convos = await Conversation.find()
const convos = await Conversation.find({ user })
.sort({ createdAt: -1, created: -1 })
.skip((pageNumber - 1) * pageSize)
.limit(pageSize)
@@ -118,74 +74,83 @@ module.exports = {
return { message: 'Error getting conversations' };
}
},
getConvo,
getConvoTitle: async (conversationId) => {
getConvosQueried: async (user, convoIds, pageNumber = 1, pageSize = 12) => {
try {
const convo = await getConvo(conversationId);
return convo.title;
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: cleanUpPrimaryKeyValue(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 conversation title' };
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;
},
migrateDb: async () => {
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' };
}
}
};

View File

@@ -12,16 +12,20 @@ const customGptSchema = mongoose.Schema({
type: String,
required: true
},
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) {
@@ -31,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();
@@ -56,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();
@@ -67,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' };

View File

@@ -1,51 +1,6 @@
const mongoose = require('mongoose');
const messageSchema = mongoose.Schema({
messageId: {
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
},
isCreatedByUser: {
type: Boolean,
required: true,
default: false
},
error: {
type: Boolean,
default: false
},
}, { timestamps: true });
const Message = mongoose.models.Message || mongoose.model('Message', messageSchema);
const Message = require('./schema/messageSchema');
module.exports = {
Message,
saveMessage: async ({ messageId, conversationId, parentMessageId, sender, text, isCreatedByUser=false, error }) => {
try {
await Message.findOneAndUpdate({ messageId }, {
@@ -62,6 +17,23 @@ module.exports = {
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()

View File

@@ -1,15 +1,16 @@
const { saveMessage, deleteMessagesSince, deleteMessages } = require('./Message');
const { getMessages, saveMessage, saveBingMessage, deleteMessagesSince, deleteMessages } = require('./Message');
const { getCustomGpts, updateCustomGpt, updateByLabel, deleteCustomGpts } = require('./CustomGpt');
const { getConvoTitle, getConvo, saveConvo, migrateDb } = require('./Conversation');
const { getConvoTitle, getConvo, saveConvo } = require('./Conversation');
module.exports = {
getMessages,
saveMessage,
saveBingMessage,
deleteMessagesSince,
deleteMessages,
getConvoTitle,
getConvo,
saveConvo,
migrateDb,
getCustomGpts,
updateCustomGpt,
updateByLabel,

View 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();
});
};

View File

@@ -0,0 +1,67 @@
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
},
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;

View 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;

2712
api/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -21,16 +21,24 @@
"dependencies": {
"@keyv/mongo": "^2.1.8",
"@vscode/vscode-languagedetection": "^1.0.22",
"@waylaidwanderer/chatgpt-api": "^1.28.2",
"@waylaidwanderer/chatgpt-api": "^1.31.6",
"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",
"mongomeili": "^0.1.8",
"mongoose": "^6.9.0",
"openai": "^3.1.0",
"sanitize-html": "^2.10.0"
"sanitize": "^2.1.2"
},
"devDependencies": {
"nodemon": "^2.0.20",

View 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.');
}
};

View File

@@ -1,36 +1,96 @@
const express = require('express');
const dbConnect = require('../models/dbConnect');
const { migrateDb } = require('../models');
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 host = process.env.HOST || 'localhost';
const userSystemEnabled = process.env.ENABLE_USER_SYSTEM || false;
const projectPath = path.join(__dirname, '..', '..', 'client');
dbConnect().then(() => {
(async () => {
await connectDb();
console.log('Connected to MongoDB');
migrateDb();
await migrateDb();
await indexSync();
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(cors());
app.use(express.json());
app.use(express.static(path.join(projectPath, 'public')));
app.get('/', function (req, res) {
console.log(path.join(projectPath, 'public', 'index.html'));
res.sendFile(path.join(projectPath, 'public', 'index.html'));
});
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, 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}`);
});

View File

@@ -12,12 +12,14 @@ router.use('/bing', askBing);
router.use('/sydney', askSydney);
router.post('/', async (req, res) => {
let { model, text, parentMessageId, conversationId: oldConversationId, ...convo } = req.body;
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();
const userParentMessageId = parentMessageId || '00000000-0000-0000-0000-000000000000';
@@ -36,51 +38,27 @@ router.post('/', async (req, res) => {
...convo
});
await saveMessage(userMessage);
await saveConvo({ ...userMessage, model, ...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
});
});
router.post('/regenerate', async (req, res) => {
const { model } = req.body;
const oldUserMessage = await getMessages({ messageId: req.body });
if (oldUserMessage) {
const convo = await getConvo(userMessage?.conversationId);
const userMessageId = crypto.randomUUID();
let userMessage = {
...userMessage,
messageId: userMessageId
};
console.log('ask log for regeneration', {
model,
...userMessage,
...convo
});
return await ask({
userMessage,
model,
convo,
preSendRequest: false,
req,
res
});
} else return handleError(res, { text: 'Parent message not found' });
});
const ask = async ({
userMessage,
overrideParentMessageId = null,
@@ -119,6 +97,14 @@ const ask = async ({
try {
const progressCallback = createOnProgress();
const abortController = new AbortController();
res.on('close', () => {
console.log('The client has disconnected.');
// 执行其他操作
abortController.abort();
})
let gptResponse = await client({
text,
onProgress: progressCallback.call(null, model, { res, text }),
@@ -127,19 +113,20 @@ const ask = async ({
conversationId,
...convo
},
...convo
...convo,
abortController
});
console.log('CLIENT RESPONSE', gptResponse);
gptResponse.text = gptResponse.response;
if (!gptResponse.parentMessageId) {
gptResponse.text = gptResponse.response;
// gptResponse.id = gptResponse.messageId;
gptResponse.parentMessageId = overrideParentMessageId || userMessageId;
userMessage.conversationId = conversationId
? conversationId
: gptResponse.conversationId;
await saveMessage(userMessage);
// userMessage.conversationId = conversationId
// ? conversationId
// : gptResponse.conversationId;
// await saveMessage(userMessage);
delete gptResponse.response;
}
@@ -164,10 +151,11 @@ const ask = async ({
// 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;
}
@@ -175,10 +163,15 @@ const ask = async ({
// 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);
await saveConvo(req?.session?.user?.username, gptResponse);
sendMessage(res, {
title: await getConvoTitle(conversationId),
title: await getConvoTitle(req?.session?.user?.username, conversationId),
final: true,
requestMessage: userMessage,
responseMessage: gptResponse
@@ -188,10 +181,14 @@ const ask = async ({
if (userParentMessageId == '00000000-0000-0000-0000-000000000000') {
const title = await titleConvo({ model, text, response: gptResponse });
await saveConvo({
conversationId,
title
});
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);

View File

@@ -2,13 +2,14 @@ const express = require('express');
const crypto = require('crypto');
const router = express.Router();
const { titleConvo, askBing } = require('../../app/');
const { saveMessage, getConvoTitle, saveConvo } = require('../../models');
const { saveBingMessage, getConvoTitle, saveConvo } = require('../../models');
const { handleError, sendMessage, createOnProgress, handleText } = require('./handlers');
router.post('/', async (req, res) => {
const {
model,
text,
overrideParentMessageId=null,
parentMessageId,
conversationId: oldConversationId,
...convo
@@ -20,7 +21,7 @@ router.post('/', async (req, res) => {
const conversationId = oldConversationId || crypto.randomUUID();
const isNewConversation = !oldConversationId;
const userMessageId = crypto.randomUUID();
const userMessageId = convo.messageId;
const userParentMessageId = parentMessageId || '00000000-0000-0000-0000-000000000000';
let userMessage = {
messageId: userMessageId,
@@ -33,12 +34,14 @@ router.post('/', async (req, res) => {
console.log('ask log', {
model,
...userMessage,
...convo
...convo,
...userMessage
});
await saveMessage(userMessage);
await saveConvo({ ...userMessage, model, ...convo });
if (!overrideParentMessageId) {
await saveBingMessage(userMessage);
await saveConvo(req?.session?.user?.username, { model, ...convo, ...userMessage });
}
return await ask({
isNewConversation,
@@ -46,6 +49,7 @@ router.post('/', async (req, res) => {
model,
convo,
preSendRequest: true,
overrideParentMessageId,
req,
res
});
@@ -80,6 +84,14 @@ const ask = async ({
try {
const progressCallback = createOnProgress();
const abortController = new AbortController();
res.on('close', () => {
console.log('The client has disconnected.');
// 执行其他操作
abortController.abort();
})
let response = await askBing({
text,
onProgress: progressCallback.call(null, model, {
@@ -91,32 +103,37 @@ const ask = async ({
...convo,
parentMessageId: userParentMessageId,
conversationId
}
},
abortController
});
console.log('BING RESPONSE', response);
// console.dir(response, { depth: null });
const hasCitations = response.response.match(citationRegex)?.length > 0;
userMessage.conversationSignature =
convo.conversationSignature || response.conversationSignature;
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 });
// 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({
conversationId: conversationId,
newConversationId: userMessage.conversationId
});
await saveConvo(
req?.session?.user?.username,
{
conversationId: conversationId,
newConversationId: userMessage.conversationId
}
);
conversationId = userMessage.conversationId;
response.text = response.response;
delete response.response;
response.text = response.response || response.details.spokenText || '**Bing refused to answer.**';
// delete response.response;
// response.id = response.details.messageId;
response.suggestions =
response.details.suggestedResponses &&
@@ -124,15 +141,17 @@ const ask = async ({
response.sender = model;
// response.final = true;
response.messageId = response.details.messageId;
// override the parentMessageId, for the regeneration.
response.parentMessageId =
overrideParentMessageId || response.parentMessageId || userMessageId;
overrideParentMessageId || response.details.requestId || userMessageId;
response.text = await handleText(response, true);
await saveMessage(response);
await saveConvo({ ...response, model, chatGptLabel: null, promptPrefix: null, ...convo });
await saveBingMessage(response);
await saveConvo(req?.session?.user?.username, { model, chatGptLabel: null, promptPrefix: null, ...convo, ...response });
sendMessage(res, {
title: await getConvoTitle(conversationId),
title: await getConvoTitle(req?.session?.user?.username, conversationId),
final: true,
requestMessage: userMessage,
responseMessage: response
@@ -142,10 +161,13 @@ const ask = async ({
if (userParentMessageId == '00000000-0000-0000-0000-000000000000') {
const title = await titleConvo({ model, text, response });
await saveConvo({
conversationId,
title
});
await saveConvo(
req?.session?.user?.username,
{
conversationId,
title
}
);
}
} catch (error) {
console.log(error);
@@ -158,9 +180,9 @@ const ask = async ({
error: true,
text: error.message
};
await saveMessage(errorMessage);
await saveBingMessage(errorMessage);
handleError(res, errorMessage);
}
};
module.exports = router;
module.exports = router;

View File

@@ -2,13 +2,14 @@ const express = require('express');
const crypto = require('crypto');
const router = express.Router();
const { titleConvo, askSydney } = require('../../app/');
const { saveMessage, saveConvo, getConvoTitle } = require('../../models');
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
@@ -20,7 +21,7 @@ router.post('/', async (req, res) => {
const conversationId = oldConversationId || crypto.randomUUID();
const isNewConversation = !oldConversationId;
const userMessageId = crypto.randomUUID();
const userMessageId = convo.messageId;
const userParentMessageId = parentMessageId || '00000000-0000-0000-0000-000000000000';
let userMessage = {
messageId: userMessageId,
@@ -33,12 +34,14 @@ router.post('/', async (req, res) => {
console.log('ask log', {
model,
...userMessage,
...convo
...convo,
...userMessage
});
await saveMessage(userMessage);
await saveConvo({ ...userMessage, model, ...convo });
if (!overrideParentMessageId) {
await saveBingMessage(userMessage);
await saveConvo(req?.session?.user?.username, { model, ...convo, ...userMessage });
}
return await ask({
isNewConversation,
@@ -46,6 +49,7 @@ router.post('/', async (req, res) => {
model,
convo,
preSendRequest: true,
overrideParentMessageId,
req,
res
});
@@ -80,6 +84,14 @@ const ask = async ({
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, {
@@ -88,10 +100,11 @@ const ask = async ({
parentMessageId: overrideParentMessageId || userMessageId
}),
convo: {
...convo,
parentMessageId: userParentMessageId,
conversationId,
...convo
}
conversationId
},
abortController
});
console.log('SYDNEY RESPONSE', response);
@@ -101,8 +114,10 @@ const ask = async ({
convo.conversationSignature || response.conversationSignature;
userMessage.conversationId = response.conversationId || conversationId;
userMessage.invocationId = response.invocationId;
userMessage.messageId = response.parentMessageId || userMessageId;
// Unlike gpt and bing, Sydney will never accept our given userMessage.messageId, it will generate its own one.
await saveMessage(userMessage);
if (!overrideParentMessageId)
await saveBingMessage({ oldMessageId: userMessageId, ...userMessage });
// Save sydney response
// response.id = response.messageId;
@@ -111,8 +126,8 @@ const ask = async ({
response.conversationSignature = convo.conversationSignature
? convo.conversationSignature
: crypto.randomUUID();
response.text = response.response;
delete response.response;
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);
@@ -125,25 +140,30 @@ const ask = async ({
// Save user message
userMessage.conversationId = response.conversationId || conversationId;
await saveMessage(userMessage);
if (!overrideParentMessageId)
await saveBingMessage(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({
conversationId: conversationId,
newConversationId: userMessage.conversationId
});
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 saveMessage(response);
await saveConvo({ ...response, model, chatGptLabel: null, promptPrefix: null, ...convo });
await saveBingMessage(response);
await saveConvo(req?.session?.user?.username, { model, chatGptLabel: null, promptPrefix: null, ...convo, ...response });
sendMessage(res, {
title: await getConvoTitle(conversationId),
title: await getConvoTitle(req?.session?.user?.username, conversationId),
final: true,
requestMessage: userMessage,
responseMessage: response
@@ -153,10 +173,13 @@ const ask = async ({
if (userParentMessageId == '00000000-0000-0000-0000-000000000000') {
const title = await titleConvo({ model, text, response });
await saveConvo({
conversationId,
title
});
await saveConvo(
req?.session?.user?.username,
{
conversationId,
title
}
);
}
} catch (error) {
console.log(error);
@@ -169,9 +192,9 @@ const ask = async ({
error: true,
text: error.message
};
await saveMessage(errorMessage);
await saveBingMessage(errorMessage);
handleError(res, errorMessage);
}
};
module.exports = router;
module.exports = router;

46
api/server/routes/auth.js Normal file
View File

@@ -0,0 +1,46 @@
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 (error) {
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 };

View File

@@ -0,0 +1,40 @@
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
req.session.user = {
username: 'sample_user',
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;

View File

@@ -1,10 +1,42 @@
const express = require('express');
const router = express.Router();
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 getConvosByPage(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);

View File

@@ -8,7 +8,7 @@ const {
} = 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;
@@ -20,8 +20,8 @@ router.post('/delete', async (req, res) => {
const { arg } = req.body;
try {
await deleteCustomGpts(arg);
const models = (await getCustomGpts()).map((model) => {
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;
@@ -56,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);

View File

@@ -1,8 +1,9 @@
const _ = require('lodash');
const sanitizeHtml = require('sanitize-html');
const citationRegex = /\[\^\d+?\^]/g;
const { getCitations, citeText, detectCode } = require('../../app/');
// const htmlTagRegex = /(<\/?\s*[a-zA-Z]*\s*(?:\s+[a-zA-Z]+\s*=\s*(?:"[^"]*"|'[^']*'))*\s*(?:\/?)>|<\s*[a-zA-Z]+\s*(?:\s+[a-zA-Z]+\s*=\s*(?:"[^"]*"|'[^']*'))*\s*(?:\/?>|<\/?>))/g;
const backtick = /(?<!`)[`](?!`)/g;
// const singleBacktick = /(?<!`)[`](?!`)/;
const cursorDefault = '<span class="result-streaming">█</span>';
const { getCitations, citeText } = require('../../app/');
const handleError = (res, message) => {
res.write(`event: error\ndata: ${JSON.stringify(message)}\n\n`);
@@ -18,29 +19,52 @@ const sendMessage = (res, message) => {
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 }) => {
tokens += partial === text ? '' : partial;
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/, '');
}
// const htmlTags = tokens.match(htmlTagRegex);
// if (tokens.includes('```') && htmlTags && htmlTags.length > 0) {
// htmlTags.forEach((tag) => {
// const sanitizedTag = sanitizeHtml(tag);
// tokens = tokens.replaceAll(tag, sanitizedTag);
// });
// }
if (bing) {
tokens = citeText(tokens, true);
}
sendMessage(res, { text: tokens, message: true, initial: i === 0, ...rest });
sendMessage(res, { text: tokens + cursor, message: true, initial: i === 0, ...rest });
i++;
};
@@ -54,7 +78,7 @@ const createOnProgress = () => {
const handleText = async (response, bing = false) => {
let { text } = response;
text = await detectCode(text);
// text = await detectCode(text);
response.text = text;
if (bing) {
@@ -66,14 +90,6 @@ const handleText = async (response, bing = false) => {
text += links?.length > 0 ? `\n<small>${links}</small>` : '';
}
// const htmlTags = text.match(htmlTagRegex);
// if (text.includes('```') && htmlTags && htmlTags.length > 0) {
// htmlTags.forEach((tag) => {
// const sanitizedTag = sanitizeHtml(tag);
// text = text.replaceAll(tag, sanitizedTag);
// });
// }
return text;
};

View File

@@ -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 };

118
api/server/routes/search.js Normal file
View File

@@ -0,0 +1,118 @@
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 {
const user = req?.session?.user?.username;
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;
console.log('message hits:', messages.length, 'convo hits:', titles.length);
const sortedHits = reduceHits(messages, titles);
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;

View File

@@ -1,2 +0,0 @@
/node_modules
.env

30
client/.eslintrc.js Normal file
View 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
View 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"
}

View File

@@ -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/
# Build webpack artifacts
RUN 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 .

2787
client/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -30,17 +30,29 @@
"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"
"url": "^0.11.0",
"uuidv4": "^6.2.13"
},
"devDependencies": {
"@babel/cli": "^7.20.7",

View File

@@ -2,6 +2,7 @@
<html>
<head>
<meta charset="utf-8" />
<meta name="theme-color" content="#343541">
<title>ChatGPT Clone</title>
<link
rel="shortcut icon"

View File

@@ -5,34 +5,52 @@ 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 dispatch = useDispatch();
const { messages, messageTree } = useSelector((state) => state.messages);
const { user } = useSelector((state) => state.user);
const { title } = useSelector((state) => state.convo);
const { conversationId } = useSelector((state) => state.convo);
const [ navVisible, setNavVisible ]= useState(false)
const [navVisible, setNavVisible] = useState(false);
useDocumentTitle(title);
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 ? (
<Landing title={title} />
) : (
<Messages
messages={messages}
messageTree={messageTree}
/>
)}
<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;

View File

@@ -58,7 +58,8 @@ export default function Conversation({
jailbreakConversationId,
conversationSignature,
clientId,
invocationId
invocationId,
latestMessage: null
})
);
} else {
@@ -69,7 +70,8 @@ export default function Conversation({
jailbreakConversationId: null,
conversationSignature: null,
clientId: null,
invocationId: null
invocationId: null,
latestMessage: null
})
);
}

View 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}
>
&lt;&lt;
</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}
>
&gt;&gt;
</button>
</div>
);
}

View File

@@ -1,11 +1,7 @@
import React from 'react';
import Conversation from './Conversation';
export default function Conversations({ conversations, conversationId, pageNumber, pages, nextPage, previousPage, moveToTop }) {
const clickHandler = (func) => async (e) => {
e.preventDefault();
await func();
};
export default function Conversations({ conversations, conversationId, moveToTop }) {
return (
<>
@@ -37,25 +33,6 @@ export default function Conversations({ conversations, conversationId, pageNumbe
/>
);
})}
<div className="m-auto mt-4 mb-2 flex justify-center items-center gap-2">
<button
onClick={clickHandler(previousPage)}
className={"flex btn btn-small transition bg-transition dark:text-white disabled:text-gray-300 dark:disabled:text-gray-400 m-auto gap-2 hover:bg-gray-800" + (pageNumber<=1?" hidden-visibility":"")}
disabled={pageNumber<=1}
>
&lt;&lt;
</button>
<span className="flex-none text-gray-400">
{pageNumber} / {pages}
</span>
<button
onClick={clickHandler(nextPage)}
className={"flex btn btn-small transition bg-transition dark:text-white disabled:text-gray-300 dark:disabled:text-gray-400 m-auto gap-2 hover:bg-gray-800" + (pageNumber>=pages?" hidden-visibility":"")}
disabled={pageNumber>=pages}
>
&gt;&gt;
</button>
</div>
</>
);
}

View File

@@ -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"

View File

@@ -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">

View File

@@ -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-0 h-[50px] w-[30px] right-1 rounded-md p-1 text-gray-500 hover:bg-gray-100disabled:hover:bg-transparent dark:hover:bg-gray-900 dark:hover:text-gray-400 dark:disabled:hover:bg-transparent md:right-2" disabled>
<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-0 flex justify-center items-center h-[50px] w-[50px] right-0 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"
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>
);
}

View File

@@ -7,33 +7,103 @@ import Footer from './Footer';
import TextareaAutosize from 'react-textarea-autosize';
import createPayload from '~/utils/createPayload';
import resetConvo from '~/utils/resetConvo';
import RegenerateIcon from '../svg/RegenerateIcon';
import StopGeneratingIcon from '../svg/StopGeneratingIcon';
import { useSelector, useDispatch } from 'react-redux';
import { setConversation, setNewConvo, setError, refreshConversation } from '~/store/convoSlice';
import {
setConversation,
setNewConvo,
setError,
refreshConversation
} from '~/store/convoSlice';
import { setMessages } from '~/store/messageSlice';
import { setSubmitState, setSubmission } 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 inputRef = useRef(null);
const isComposing = useRef(false);
const dispatch = useDispatch();
const { user } = useSelector((state) => state.user);
const convo = useSelector((state) => state.convo);
const { initial } = useSelector((state) => state.models);
const { isSubmitting, stopStream, submission, disabled, model, chatGptLabel, promptPrefix } =
useSelector((state) => state.submit);
const { text } = useSelector((state) => state.text);
const { error } = convo;
const { error, latestMessage } = convo;
const { ask, regenerate, stopGenerating } = useMessageHandler();
const isNotAppendable = latestMessage?.cancelled || latestMessage?.error;
// auto focus to input, when enter a conversation.
useEffect(() => {
inputRef.current?.focus();
}, [convo?.conversationId,])
}, [convo?.conversationId]);
const messageHandler = (data, currentState, currentMsg) => {
const { messages, _currentMsg, message, sender } = currentState;
const { messages, _currentMsg, message, sender, isRegenerate } = currentState;
dispatch(setMessages([...messages, currentMsg, { sender, text: data, parentMessageId: currentMsg?.messageId, messageId: currentMsg?.messageId + '_', submitting: true }]));
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 cancelHandler = (data, currentState, currentMsg) => {
const { messages, _currentMsg, 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) => {
@@ -41,6 +111,7 @@ export default function TextChat({ messages }) {
dispatch(
setConversation({
conversationId,
latestMessage: null
})
);
};
@@ -48,15 +119,16 @@ export default function TextChat({ messages }) {
const convoHandler = (data, currentState, currentMsg) => {
const { requestMessage, responseMessage } = data;
const { conversationId } = requestMessage;
const { messages, _currentMsg, message, isCustomModel, sender } =
const { messages, _currentMsg, message, isCustomModel, sender, isRegenerate } =
currentState;
const { model, chatGptLabel, promptPrefix } = message;
dispatch(
setMessages([...messages, requestMessage, responseMessage,])
);
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());
@@ -81,23 +153,24 @@ export default function TextChat({ messages }) {
clientId: null,
invocationId: null,
chatGptLabel: model === isCustomModel ? chatGptLabel : null,
promptPrefix: model === isCustomModel ? promptPrefix : null
promptPrefix: model === isCustomModel ? promptPrefix : null,
latestMessage: null
})
);
} else if (
model === 'bingai'
) {
} else if (model === 'bingai') {
console.log('Bing data:', data);
const { title } = data;
const { conversationSignature, clientId, conversationId, invocationId } = responseMessage;
const { conversationSignature, clientId, conversationId, invocationId, parentMessageId } =
responseMessage;
dispatch(
setConversation({
title,
parentMessageId: null,
parentMessageId,
conversationSignature,
clientId,
conversationId,
invocationId
invocationId,
latestMessage: null
})
);
} else if (model === 'sydney') {
@@ -118,12 +191,11 @@ export default function TextChat({ messages }) {
conversationSignature,
clientId,
conversationId,
invocationId
invocationId,
latestMessage: null
})
);
}
dispatch(setSubmitState(false));
};
const errorHandler = (data, currentState, currentMsg) => {
@@ -132,7 +204,7 @@ export default function TextChat({ messages }) {
const errorResponse = {
...data,
error: true,
parentMessageId: currentMsg?.messageId,
parentMessageId: currentMsg?.messageId
};
setErrorMessage(data?.text);
dispatch(setSubmitState(false));
@@ -141,51 +213,8 @@ export default function TextChat({ messages }) {
dispatch(setError(true));
return;
};
const submitMessage = () => {
if (error) {
dispatch(setError(false));
}
if (!!isSubmitting || text.trim() === '') {
return;
}
// this is not a real messageId, it is used as placeholder before real messageId returned
const fakeMessageId = crypto.randomUUID();
const isCustomModel = model === 'chatgptCustom' || !initial[model];
const message = text.trim();
const sender = model === 'chatgptCustom' ? chatGptLabel : model;
let parentMessageId = convo.parentMessageId || '00000000-0000-0000-0000-000000000000';
let currentMessages = messages;
if (resetConvo(currentMessages, sender)) {
parentMessageId = '00000000-0000-0000-0000-000000000000';
dispatch(setNewConvo());
currentMessages = [];
}
const currentMsg = { sender: 'User', text: message, current: true, isCreatedByUser: true, parentMessageId , messageId: fakeMessageId };
const initialResponse = { sender, text: '', parentMessageId: fakeMessageId, submitting: true };
dispatch(setSubmitState(true));
dispatch(setMessages([...currentMessages, currentMsg, initialResponse]));
dispatch(setText(''));
const submission = {
convo,
isCustomModel,
message: {
...currentMsg,
model,
chatGptLabel,
promptPrefix,
},
messages: currentMessages,
currentMsg,
initialResponse,
sender,
};
console.log('User Input:', message);
dispatch(setSubmission(submission));
ask({ text });
};
useEffect(() => {
@@ -195,7 +224,10 @@ export default function TextChat({ messages }) {
}
const currentState = submission;
let currentMsg = currentState.currentMsg;
let currentMsg = { ...currentState.message };
let latestResponseText = '';
const { server, payload } = createPayload(submission);
const onMessage = (e) => {
if (stopStream) {
@@ -204,19 +236,21 @@ export default function TextChat({ messages }) {
const data = JSON.parse(e.data);
// if (data.message) {
// messageHandler(text, currentState);
// }
if (data.final) {
convoHandler(data, currentState, currentMsg);
dispatch(toggleCursor());
console.log('final', data);
} if (data.created) {
}
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);
@@ -234,12 +268,17 @@ export default function TextChat({ messages }) {
events.onmessage = onMessage;
events.oncancel = (e) => {
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);
};
@@ -247,18 +286,31 @@ export default function TextChat({ messages }) {
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();
if (!isComposing.current) submitMessage();
}
};
@@ -271,21 +323,21 @@ export default function TextChat({ messages }) {
return;
}
};
const handleCompositionStart = (e) => {
isComposing.current = true
}
isComposing.current = true;
};
const handleCompositionEnd = (e) => {
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));
};
@@ -294,45 +346,77 @@ export default function TextChat({ messages }) {
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="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">
<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}
<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">
{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 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'
} dark:text-white dark:shadow-[0_0_15px_rgba(0,0,0,0.10)] md:py-3 md:pl-4`}
>
<ModelMenu />
<TextareaAutosize
tabIndex="0"
autoFocus
ref={inputRef}
// style={{maxHeight: '200px', height: '24px', overflowY: 'hidden'}}
rows="1"
value={disabled || isNotAppendable ? '' : text}
onKeyUp={handleKeyUp}
onKeyDown={handleKeyDown}
onChange={changeHandler}
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 dark:placeholder:text-gray-500 focus:outline-none focus:ring-0 focus-visible:ring-0 dark:bg-transparent md:pl-8"
/>
) : (
<div
className={`relative flex w-full 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'
} dark:text-white dark:shadow-[0_0_15px_rgba(0,0,0,0.10)] md:py-3 md:pl-4`}
>
<ModelMenu />
<TextareaAutosize
tabIndex="0"
autoFocus
ref={inputRef}
// style={{maxHeight: '200px', height: '24px', overflowY: 'hidden'}}
rows="1"
value={text}
onKeyUp={handleKeyUp}
onKeyDown={handleKeyDown}
onChange={changeHandler}
onCompositionStart={handleCompositionStart}
onCompositionEnd={handleCompositionEnd}
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"
/>
<SubmitButton submitMessage={submitMessage} />
</div>
)}
<SubmitButton
submitMessage={submitMessage}
disabled={disabled || isNotAppendable}
/>
</div>
</div>
</form>
<Footer />

View 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;

View File

@@ -0,0 +1,89 @@
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 = false }) => {
const rehypePlugins = [
[rehypeKatex, { output: 'mathml' }],
[
rehypeHighlight,
{
detect: true,
ignoreMissing: true,
subset: langSubset
}
],
[rehypeRaw],
]
return (
<>
<ReactMarkdown
remarkPlugins={[remarkGfm, [remarkMath, { singleDollarTextMath: false }]]}
rehypePlugins={isCreatedByUser ? rehypePlugins.slice(-1) : 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;

View File

@@ -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;

View 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 = 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;

View 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>
);
}

View 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;

View File

@@ -1,18 +0,0 @@
import React, { useState, useEffect } from 'react';
import hljs from 'highlight.js';
import languages from '~/utils/languages';
export default function Highlight({language, code}) {
const [highlightedCode, setHighlightedCode] = useState(code);
const lang = languages.has(language) ? language : 'shell';
useEffect(() => {
setHighlightedCode(hljs.highlight(code, { language: lang }).value);
}, [code, lang]);
return (
<pre>
<code className={`language-${lang}`} dangerouslySetInnerHTML={{__html: highlightedCode}}/>
</pre>
);
}

View File

@@ -3,7 +3,7 @@ import React from 'react';
import EditIcon from '../svg/EditIcon';
export default function HoverButtons({ visible, onClick, model }) {
const isBing = model === 'bingai' || model === 'sydney';
const isBing = model === 'bingai';
const enabled = !isBing;
return (

View File

@@ -1,15 +1,16 @@
import React, { useState, useEffect, useRef, useCallback } from 'react';
import TextWrapper from './TextWrapper';
import MultiMessage from './MultiMessage';
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 { setError } from '~/store/convoSlice';
import { setConversation, setLatestMessage } from '~/store/convoSlice';
import { setModel, setCustomModel, setCustomGpt, setDisabled } from '~/store/submitSlice';
import { setMessages } from '~/store/messageSlice';
import { setSubmitState, setSubmission } from '~/store/submitSlice';
import { setText } from '~/store/textSlice';
import { setConversation } from '../../store/convoSlice';
import { getIconOfModel } from '../../utils';
import { fetchById } from '~/utils/fetchers';
import { getIconOfModel } from '~/utils';
import { useMessageHandler } from '~/utils/handleSubmit';
export default function Message({
message,
@@ -21,40 +22,46 @@ export default function Message({
siblingCount,
setSiblingIdx
}) {
const { isSubmitting, model, chatGptLabel, promptPrefix } = useSelector(
(state) => state.submit
);
const { isSubmitting, model, chatGptLabel, cursor, promptPrefix } = useSelector(state => state.submit);
const [abortScroll, setAbort] = useState(false);
const { sender, text, isCreatedByUser, error, submitting } = message;
const { sender, text, searchResult, isCreatedByUser, error, submitting } = message;
const textEditor = useRef(null);
const convo = useSelector((state) => state.convo);
const { initial } = useSelector((state) => state.models);
const { error: convoError } = convo;
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 && last && !isCreatedByUser;
const blinker = submitting && isSubmitting;
const generateCursor = useCallback(() => {
if (!blinker) {
return '';
}
if (!cursor) {
return '';
}
return <span className="result-streaming"></span>;
}, [blinker]);
}, [blinker, cursor]);
useEffect(() => {
if (blinker && !abortScroll) {
scrollToBottom();
}
}, [isSubmitting, text, blinker, scrollToBottom, abortScroll]);
}, [isSubmitting, blinker, text, scrollToBottom]);
useEffect(() => {
if (last) dispatch(setConversation({ parentMessageId: message?.messageId }));
}, [last]);
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 enterEdit = cancel => setCurrentEditId(cancel ? -1 : message.messageId);
const handleWheel = () => {
if (blinker) {
@@ -73,6 +80,7 @@ export default function Message({
sender,
isCreatedByUser,
model,
searchResult,
chatGptLabel,
promptPrefix,
error
@@ -82,74 +90,54 @@ export default function Message({
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]';
// const wrapText = (text) => <TextWrapper text={text} generateCursor={generateCursor}/>;
if (message.bg && searchResult) {
props.className = message.bg.split('hover')[0];
props.titleClass = message.bg.split(props.className)[1] + ' cursor-pointer';
}
const resubmitMessage = () => {
const text = textEditor.current.innerText;
if (convoError) {
dispatch(setError(false));
}
if (!!isSubmitting || text.trim() === '') {
return;
}
// this is not a real messageId, it is used as placeholder before real messageId returned
const fakeMessageId = crypto.randomUUID();
const isCustomModel = model === 'chatgptCustom' || !initial[model];
const currentMsg = {
sender: 'User',
text: text.trim(),
current: true,
isCreatedByUser: true,
ask({
text,
parentMessageId: message?.parentMessageId,
conversationId: message?.conversationId,
messageId: fakeMessageId
};
const sender = model === 'chatgptCustom' ? chatGptLabel : model;
const initialResponse = {
sender,
text: '',
parentMessageId: fakeMessageId,
submitting: true
};
dispatch(setSubmitState(true));
dispatch(setMessages([...messages, currentMsg, initialResponse]));
dispatch(setText(''));
const submission = {
isCustomModel,
message: {
...currentMsg,
model,
chatGptLabel,
promptPrefix
},
messages: messages,
currentMsg,
initialResponse,
sender
};
console.log('User Input:', currentMsg?.text);
// handleSubmit(submission);
dispatch(setSubmission(submission));
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}
// 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(/[^\u0000-\u007F]+/) ? (
{typeof icon === 'string' && icon.match(/[^\\x00-\\x7F]+/) ? (
<span className=" direction-rtl w-40 overflow-x-scroll">{icon}</span>
) : (
icon
@@ -163,6 +151,15 @@ export default function Message({
</div>
</div>
<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">
@@ -202,32 +199,28 @@ export default function Message({
<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">
{!isCreatedByUser ? (
<TextWrapper
text={text}
generateCursor={generateCursor}
/>
) : (
text
)}
<Wrapper
text={text}
generateCursor={generateCursor}
isCreatedByUser={isCreatedByUser}
searchResult={searchResult}
/>
</div>
</div>
)}
</div>
<HoverButtons
model={model}
visible={!error && isCreatedByUser && !edit}
visible={!error && isCreatedByUser && !edit && !searchResult}
onClick={() => enterEdit()}
/>
<div className="sibling-switch-container flex justify-between">
<div className="flex items-center justify-center gap-1 self-center pt-2 text-xs">
<SiblingSwitch
siblingIdx={siblingIdx}
siblingCount={siblingCount}
setSiblingIdx={setSiblingIdx}
/>
</div>
</div>
<SubRow subclasses="switch-container">
<SiblingSwitch
siblingIdx={siblingIdx}
siblingCount={siblingCount}
setSiblingIdx={setSiblingIdx}
/>
</SubRow>
</div>
</div>
</div>

View 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;

View File

@@ -1,4 +1,4 @@
import React, { useState } from 'react';
import React, { useEffect, useState } from 'react';
import Message from './Message';
export default function MultiMessage({
@@ -6,7 +6,7 @@ export default function MultiMessage({
messages,
scrollToBottom,
currentEditId,
setCurrentEditId
setCurrentEditId,
}) {
const [siblingIdx, setSiblingIdx] = useState(0);
@@ -14,6 +14,11 @@ export default function MultiMessage({
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;
@@ -24,10 +29,11 @@ export default function MultiMessage({
return null;
}
const message = messageList[messageList.length - siblingIdx - 1];
return (
<Message
key={messageList[messageList.length - siblingIdx - 1].messageId}
message={messageList[messageList.length - siblingIdx - 1]}
key={message.messageId}
message={message}
messages={messages}
scrollToBottom={scrollToBottom}
currentEditId={currentEditId}

View File

@@ -1,40 +1,50 @@
import React, { useEffect, useState, useRef, useMemo } 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 MultiMessage from './MultiMessage';
import { useSelector } from 'react-redux';
const Messages = ({ messages, messageTree }) => {
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);
@@ -54,14 +64,17 @@ const Messages = ({ messages, messageTree }) => {
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="dark:gpt-dark-gray h-full">
<div className="dark:gpt-dark-gray flex h-full flex-col items-center text-sm">
{messageTree.length === 0 ? (
<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 />
) : (
<>
@@ -93,6 +106,4 @@ const Messages = ({ messages, messageTree }) => {
{/* </div> */}
</div>
);
};
export default React.memo(Messages);
}

View File

@@ -28,7 +28,7 @@ export default function ModelItem({ modelName, value, model, onSelect, id, chatG
dispatch(setModels(fetchedModels));
});
const icon = getIconOfModel({ size: 16, sender: modelName, isCreatedByUser: false, model, chatGptLabel, promptPrefix, error: false, className: "mr-2" });
const icon = getIconOfModel({ size: 20, sender: modelName, isCreatedByUser: false, model, chatGptLabel, promptPrefix, error: false, className: "mr-2" });
if (value === 'chatgptCustom') {
return (
@@ -43,9 +43,9 @@ export default function ModelItem({ modelName, value, model, onSelect, id, chatG
</DropdownMenuRadioItem>
</DialogTrigger>
);
}
}
if (initial[value]) {
if (initial[value])
return (
<DropdownMenuRadioItem
value={value}
@@ -56,8 +56,8 @@ export default function ModelItem({ modelName, value, model, onSelect, id, chatG
{value === 'chatgpt' && <sup>$</sup>}
</DropdownMenuRadioItem>
);
}
const handleMouseOver = () => {
setIsHovering(true);
};
@@ -143,7 +143,7 @@ export default function ModelItem({ modelName, value, model, onSelect, id, chatG
onKeyDown={handleKeyDown}
/>
) : (
<div className="w-3/4 overflow-hidden">{modelInput}</div>
<div className=" overflow-hidden">{modelInput}</div>
)}
{value === 'chatgpt' && <sup>$</sup>}

View File

@@ -1,4 +1,5 @@
import React, { useState, useEffect } from 'react';
import axios from 'axios';
import { useSelector, useDispatch } from 'react-redux';
import {
setSubmission,
@@ -11,12 +12,11 @@ import { setNewConvo } from '~/store/convoSlice';
import ModelDialog from './ModelDialog';
import MenuItems from './MenuItems';
import { swr } from '~/utils/fetchers';
import { setModels } from '~/store/modelSlice';
import { setModels, setInitial } from '~/store/modelSlice';
import { setMessages } from '~/store/messageSlice';
import { setText } from '~/store/textSlice';
import GPTIcon from '../svg/GPTIcon';
import BingIcon from '../svg/BingIcon';
import { Button } from '../ui/Button.tsx';
import { getIconOfModel } from '../../utils';
import {
DropdownMenu,
@@ -33,12 +33,13 @@ 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 { data, isLoading, mutate } = swr(`/api/customGpts`, (res) => {
const fetchedModels = res.map((modelItem) => ({
...modelItem,
name: modelItem.chatGptLabel
name: modelItem.chatGptLabel,
model: 'chatgptCustom'
}));
dispatch(setModels(fetchedModels));
@@ -61,10 +62,47 @@ export default function ModelMenu() {
// 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 filteredModels = models.filter(({model, _id }) => initial[model] );
const onChange = (value) => {
if (!value) {
return;
@@ -138,7 +176,7 @@ export default function ModelMenu() {
const isBing = model === 'bingai' || model === 'sydney';
const colorProps = model === 'chatgpt' ? chatgptColorProps : defaultColorProps;
const icon = isBing ? <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}>
@@ -150,9 +188,9 @@ 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>
@@ -165,10 +203,12 @@ 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>

View File

@@ -6,6 +6,8 @@ import { useDispatch } from 'react-redux';
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();
@@ -25,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>
);
}

View 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>
);
}

View File

@@ -23,7 +23,7 @@ export default function MobileNav({ setNavVisible }) {
}
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"

View File

@@ -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 />
</>
);
}

View File

@@ -1,9 +1,10 @@
import React from 'react';
import { useDispatch } from 'react-redux';
import { setNewConvo } from '~/store/convoSlice';
import { setNewConvo, refreshConversation } from '~/store/convoSlice';
import { setMessages } from '~/store/messageSlice';
import { setSubmission } from '~/store/submitSlice';
import { setSubmission, setDisabled } from '~/store/submitSlice';
import { setText } from '~/store/textSlice';
import { setInputValue, setQuery } from '~/store/searchSlice';
export default function NewChat() {
const dispatch = useDispatch();
@@ -12,7 +13,11 @@ export default function NewChat() {
dispatch(setText(''));
dispatch(setMessages([]));
dispatch(setNewConvo());
dispatch(refreshConversation());
dispatch(setSubmission({}));
dispatch(setDisabled(false));
dispatch(setInputValue(''));
dispatch(setQuery(''));
};
return (

View 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>
);
}

View File

@@ -1,32 +1,68 @@
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 { increasePage, decreasePage, setPage, setConvos, setPages } from '~/store/convoSlice';
import { setConvos, setNewConvo, refreshConversation } from '~/store/convoSlice';
import { setMessages } from '~/store/messageSlice';
import { setDisabled } from '~/store/submitSlice';
export default function Nav({ navVisible, setNavVisible }) {
const dispatch = useDispatch();
const [isHovering, setIsHovering] = useState(false);
const { conversationId, convos, pages, pageNumber, refreshConvoHint } = useSelector(
(state) => state.convo
);
const onSuccess = (data) => {
const { conversations, pages } = 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) {
dispatch(setPage(pages));
setPage(pages);
} else {
dispatch(setConvos(conversations));
dispatch(setPages(pages));
dispatch(setConvos({ convos: conversations, searchFetch }));
setPages(pages);
}
};
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
revalidateOnMount: false,
});
const containerRef = useRef(null);
@@ -42,19 +78,29 @@ export default function Nav({ navVisible, setNavVisible }) {
const nextPage = async () => {
moveToTop();
dispatch(increasePage());
await mutate();
if (!search) {
setPage((prev) => prev + 1);
await mutate();
} else {
await fetch(query, +pageNumber + 1);
}
};
const previousPage = async () => {
moveToTop();
dispatch(decreasePage());
await mutate();
if (!search) {
setPage((prev) => prev - 1);
await mutate();
} else {
await fetch(query, +pageNumber - 1);
}
};
useEffect(() => {
mutate();
if (!search) {
mutate();
}
}, [pageNumber, conversationId, refreshConvoHint]);
useEffect(() => {
@@ -104,22 +150,29 @@ export default function Nav({ navVisible, setNavVisible }) {
ref={containerRef}
>
<div className={containerClasses}>
{isLoading && pageNumber === 1 ? (
{/* {(isLoading && pageNumber === 1) ? ( */}
{(isLoading && pageNumber === 1) || (isFetching) ? (
<Spinner />
) : (
<Conversations
conversations={convos}
conversationId={conversationId}
nextPage={nextPage}
previousPage={previousPage}
moveToTop={moveToTop}
pageNumber={pageNumber}
pages={pages}
/>
)}
<Pages
pageNumber={pageNumber}
pages={pages}
nextPage={nextPage}
previousPage={previousPage}
/>
</div>
</div>
<NavLinks />
<NavLinks
fetch={fetch}
onSearchSuccess={onSearchSuccess}
clearSearch={clearSearch}
/>
</nav>
</div>
</div>

View File

@@ -0,0 +1,7 @@
import React from 'react';
export default function StopGeneratingIcon() {
return (
<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"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect></svg>
);
}

View File

@@ -0,0 +1,57 @@
import React from 'react';
import {
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from './Dialog.tsx';
export default function DialogTemplate({ title, description, main, buttons, selection }) {
const { selectHandler, selectClasses, selectText } = selection;
const defaultSelect = "bg-gray-900 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"
return (
<DialogContent className="shadow-2xl dark:bg-gray-800">
<DialogHeader>
<DialogTitle className="text-gray-800 dark:text-white">{title}</DialogTitle>
<DialogDescription className="text-gray-600 dark:text-gray-300">
{description}
</DialogDescription>
</DialogHeader>
{/* <div className="grid gap-4 py-4">
<div className="grid grid-cols-4 items-center gap-4"> //input template
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label
htmlFor="promptPrefix"
className="text-right"
>
Prompt Prefix
</Label>
<TextareaAutosize
id="promptPrefix"
value={promptPrefix}
onChange={(e) => setPromptPrefix(e.target.value)}
placeholder="Set custom instructions. Defaults to: 'You are ChatGPT, a large language model trained by OpenAI.'"
className="col-span-3 flex h-20 w-full resize-none rounded-md border border-gray-300 bg-transparent py-2 px-3 text-sm shadow-[0_0_10px_rgba(0,0,0,0.10)] outline-none placeholder:text-gray-400 focus:outline-none focus:ring-gray-400 focus:ring-opacity-20 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 dark:border-none dark:bg-gray-700 dark:text-gray-50 dark:shadow-[0_0_15px_rgba(0,0,0,0.10)] dark:focus:border-none dark:focus:border-transparent dark:focus:outline-none dark:focus:ring-0 dark:focus:ring-gray-400 dark:focus:ring-offset-0"
/>
</div>
</div> */}
{main ? main : null}
<DialogFooter>
<DialogClose className="dark:hover:gray-400 border-gray-700">Cancel</DialogClose>
{ buttons ? buttons : null}
<DialogClose
onClick={selectHandler}
className={`${selectClasses || defaultSelect} inline-flex h-10 items-center justify-center rounded-md border-none py-2 px-4 text-sm font-semibold`}
>
{selectText}
</DialogClose>
</DialogFooter>
</DialogContent>
);
}

View File

@@ -22,11 +22,17 @@
}
@media (min-width: 1024px) {
.sibling-switch-container {
.switch-container {
display: none;
}
}
.switch-result {
display: block !important;
visibility: visible;
}
@media (max-width: 1024px) {
/* .sibling-switch {
left: 114px;
@@ -46,6 +52,18 @@
}
@media (max-width: 767px) {
.input-panel-button {
border: 0;
}
.input-panel-button svg {
width: 16px;
height: 16px;
}
.input-panel {
}
.nav-close-button {
display: block;
position: absolute;

View File

@@ -15,18 +15,27 @@ const initialState = {
pageNumber: 1,
pages: 1,
refreshConvoHint: 0,
search: false,
latestMessage: null,
convos: [],
convoMap: {},
};
const currentSlice = createSlice({
name: 'convo',
initialState,
reducers: {
refreshConversation: (state, action) => {
refreshConversation: (state) => {
state.refreshConvoHint = state.refreshConvoHint + 1;
},
setConversation: (state, action) => {
return { ...state, ...action.payload };
// return { ...state, ...action.payload };
for (const key in action.payload) {
if (Object.hasOwnProperty.call(action.payload, key)) {
state[key] = action.payload[key];
}
}
},
setError: (state, action) => {
state.error = action.payload;
@@ -52,11 +61,22 @@ const currentSlice = createSlice({
state.chatGptLabel = null;
state.promptPrefix = null;
state.convosLoading = false;
state.latestMessage = null;
},
setConvos: (state, action) => {
state.convos = action.payload.sort(
(a, b) => new Date(b.createdAt) - new Date(a.createdAt)
);
const { convos, searchFetch } = action.payload;
if (searchFetch) {
state.convos = convos;
} else {
state.convos = convos.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
}
// state.convoMap = convos.reduce((acc, curr) => {
// acc[curr.conversationId] = { ...curr };
// delete acc[curr.conversationId].conversationId;
// return acc;
// }, {});
},
setPages: (state, action) => {
state.pages = action.payload;
@@ -66,11 +86,26 @@ const currentSlice = createSlice({
},
removeAll: (state) => {
state.convos = [];
},
setLatestMessage: (state, action) => {
state.latestMessage = action.payload;
}
}
});
export const { refreshConversation, setConversation, setPages, setConvos, setNewConvo, setError, increasePage, decreasePage, setPage, removeConvo, removeAll } =
currentSlice.actions;
export const {
refreshConversation,
setConversation,
setPages,
setConvos,
setNewConvo,
setError,
increasePage,
decreasePage,
setPage,
removeConvo,
removeAll,
setLatestMessage
} = currentSlice.actions;
export default currentSlice.reducer;

View File

@@ -1,10 +1,12 @@
import { configureStore } from '@reduxjs/toolkit';
import convoReducer from './convoSlice.js';
import messageReducer from './messageSlice.js'
import modelReducer from './modelSlice.js'
import submitReducer from './submitSlice.js'
import textReducer from './textSlice.js'
import messageReducer from './messageSlice.js';
import modelReducer from './modelSlice.js';
import submitReducer from './submitSlice.js';
import textReducer from './textSlice.js';
import userReducer from './userReducer.js';
import searchReducer from './searchSlice.js';
export const store = configureStore({
reducer: {
@@ -13,6 +15,8 @@ export const store = configureStore({
models: modelReducer,
text: textReducer,
submit: submitReducer,
user: userReducer,
search: searchReducer
},
devTools: true,
});
devTools: true
});

View File

@@ -12,7 +12,9 @@ const currentSlice = createSlice({
reducers: {
setMessages: (state, action) => {
state.messages = action.payload;
state.messageTree = buildTree(action.payload);
const groupAll = action.payload[0]?.searchResult;
if (groupAll) console.log('grouping all messages');
state.messageTree = buildTree(action.payload, groupAll);
},
setEmptyMessage: (state) => {
state.messages = [

View File

@@ -34,7 +34,7 @@ const initialState = {
},
],
modelMap: {},
initial: { chatgpt: true, chatgptCustom: true, bingai: true, sydney: true, chatgptBrowser: true }
initial: { chatgpt: false, chatgptCustom: false, bingai: false, sydney: false, chatgptBrowser: false }
// initial: { chatgpt: true, chatgptCustom: true, bingai: true, }
};
@@ -56,10 +56,13 @@ const currentSlice = createSlice({
});
state.modelMap = modelMap;
},
setInitial: (state, action) => {
state.initial = action.payload;
}
}
});
export const { setModels } = currentSlice.actions;
export const { setModels, setInitial } = currentSlice.actions;
export default currentSlice.reducer;

View File

@@ -0,0 +1,35 @@
import { createSlice } from '@reduxjs/toolkit';
const initialState = {
searchEnabled: false,
search: false,
query: '',
inputValue: '',
};
const currentSlice = createSlice({
name: 'search',
initialState,
reducers: {
setInputValue: (state, action) => {
state.inputValue = action.payload;
},
setSearchState: (state, action) => {
state.searchEnabled = action.payload;
},
setQuery: (state, action) => {
const q = action.payload;
state.query = q;
if (q === '') {
state.search = false;
} else if (q?.length > 0 && !state.search) {
state.search = true;
}
},
}
});
export const { setInputValue, setSearchState, setQuery } = currentSlice.actions;
export default currentSlice.reducer;

View File

@@ -4,11 +4,12 @@ const initialState = {
isSubmitting: false,
submission: {},
stopStream: false,
disabled: false,
disabled: true,
model: 'chatgpt',
promptPrefix: null,
chatGptLabel: null,
customModel: null,
cursor: true,
};
const currentSlice = createSlice({
@@ -33,8 +34,14 @@ const currentSlice = createSlice({
setModel: (state, action) => {
state.model = action.payload;
},
toggleCursor: (state, action) => {
if (action.payload) {
state.cursor = action.payload;
} else {
state.cursor = !state.cursor;
}
},
setCustomGpt: (state, action) => {
console.log('setCustomGpt', action.payload);
state.promptPrefix = action.payload.promptPrefix;
state.chatGptLabel = action.payload.chatGptLabel;
},
@@ -44,7 +51,7 @@ const currentSlice = createSlice({
}
});
export const { setSubmitState, setSubmission, setStopStream, setDisabled, setModel, setCustomGpt, setCustomModel } =
export const { toggleCursor, setSubmitState, setSubmission, setStopStream, setDisabled, setModel, setCustomGpt, setCustomModel } =
currentSlice.actions;
export default currentSlice.reducer;

View File

@@ -0,0 +1,19 @@
import { createSlice } from '@reduxjs/toolkit';
const initialState = {
user: null,
};
const currentSlice = createSlice({
name: 'user',
initialState,
reducers: {
setUser: (state, action) => {
state.user = action.payload;
},
}
});
export const { setUser } = currentSlice.actions;
export default currentSlice.reducer;

View File

@@ -23,6 +23,11 @@
}
} */
/* .LazyLoad {
opacity: 0;
transition: all 1s ease-in-out;
} */
p > small {
opacity: 0;
animation: fadein 3s forwards;

2086
client/src/style2.css Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,17 +1,52 @@
export default function buildTree(messages) {
const even =
'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 hover:bg-gray-100/25 hover:text-gray-700 dark:hover:bg-gray-900 dark:hover:text-gray-200';
const odd =
'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] hover:bg-gray-100/40 hover:text-gray-700 dark:hover:bg-[#3b3d49] dark:hover:text-gray-200';
export default function buildTree(messages, groupAll = false) {
let messageMap = {};
let rootMessages = [];
// Traverse the messages array and store each element in messageMap.
messages.forEach(message => {
messageMap[message.messageId] = {...message, children: []};
if (!groupAll) {
// Traverse the messages array and store each element in messageMap.
messages.forEach(message => {
messageMap[message.messageId] = { ...message, children: [] };
const parentMessage = messageMap[message.parentMessageId];
if (parentMessage)
parentMessage.children.push(messageMap[message.messageId]);
else
rootMessages.push(messageMap[message.messageId]);
const parentMessage = messageMap[message.parentMessageId];
if (parentMessage) parentMessage.children.push(messageMap[message.messageId]);
else rootMessages.push(messageMap[message.messageId]);
});
return rootMessages;
}
// Group all messages into one tree
let parentId = null;
messages.forEach((message, i) => {
messageMap[message.messageId] = { ...message, bg: i % 2 === 0 ? even : odd, children: [] };
const currentMessage = messageMap[message.messageId];
const parentMessage = messageMap[parentId];
if (parentMessage) parentMessage.children.push(currentMessage);
else rootMessages.push(currentMessage);
parentId = message.messageId;
});
return rootMessages;
}
// Group all messages by conversation, doesn't look great
// Traverse the messages array and store each element in messageMap.
// rootMessages = {};
// let parents = 0;
// messages.forEach(message => {
// if (message.conversationId in messageMap) {
// messageMap[message.conversationId].children.push(message);
// } else {
// messageMap[message.conversationId] = { ...message, bg: parents % 2 === 0 ? even : odd, children: [] };
// rootMessages.push(messageMap[message.conversationId]);
// parents++;
// }
// });
// // return Object.values(rootMessages);
// return rootMessages;
}

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