Compare commits

...

42 Commits

Author SHA1 Message Date
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
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
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
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
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
76 changed files with 8593 additions and 1012 deletions

2
.dockerignore Normal file
View File

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

1
.gitignore vendored
View File

@@ -2,6 +2,7 @@
# Logs
data-node
meili_data
logs
*.log

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;"]

View File

@@ -7,7 +7,21 @@ https://user-images.githubusercontent.com/110412045/223754183-8b7f45ce-6517-4bd5
This project was started early in Feb '23, anticipating the release of the official ChatGPT API from OpenAI, and now uses it. Through this clone, you can avoid ChatGPT Plus in favor of free or pay-per-call APIs. I will soon deploy a demo of this app. Feel free to contribute, clone, or fork. Currently dockerized.
## Updates
<details open>
<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>
<details>
<details>
<summary><strong>2023-03-16</strong></summary>
@@ -17,9 +31,6 @@ https://user-images.githubusercontent.com/110412045/223754183-8b7f45ce-6517-4bd5
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>
<details>
<summary><strong>2023-03-12</strong></summary>
@@ -157,6 +168,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)
@@ -170,7 +182,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
@@ -181,6 +193,7 @@ By default, only local machine can access this server. To share within network o
- **Provide** all credentials, (API keys, access tokens, and Mongo Connection String) in [docker-compose.yml](docker-compose.yml) under api service
- **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
@@ -262,7 +275,13 @@ The sample structure is simple. It provide three basic endpoint:
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/authYoutLogin.js` file. It's very clear and simple to tell you how to implement your user system.
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>

View File

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

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

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

@@ -24,6 +24,11 @@ const browserClient = async ({ text, onProgress, convo, abortController }) => {
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

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

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

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;

190
api/lib/db/mongoMeili.js Normal file
View File

@@ -0,0 +1,190 @@
const { MeiliSearch } = require('meilisearch');
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) };
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);
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('findOneAndUpdate', function (doc) {
doc.postSaveHook();
});
};

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;

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,5 +1,5 @@
const mongoose = require('mongoose');
const crypto = require('crypto');
const mongoMeili = require('../lib/db/mongoMeili');
const { getMessages, deleteMessages } = require('./Message');
const convoSchema = mongoose.Schema(
@@ -7,7 +7,9 @@ const convoSchema = mongoose.Schema(
conversationId: {
type: String,
unique: true,
required: true
required: true,
index: true,
meiliIndex: true
},
parentMessageId: {
type: String,
@@ -15,7 +17,8 @@ const convoSchema = mongoose.Schema(
},
title: {
type: String,
default: 'New Chat'
default: 'New Chat',
meiliIndex: true
},
jailbreakConversationId: {
type: String,
@@ -52,6 +55,13 @@ const convoSchema = mongoose.Schema(
{ timestamps: true }
);
// convoSchema.plugin(mongoMeili, {
// host: process.env.MEILI_HOST,
// apiKey: process.env.MEILI_KEY,
// indexName: 'convos', // Will get created automatically if it doesn't exist already
// primaryKey: 'conversationId'
// });
const Conversation =
mongoose.models.Conversation || mongoose.model('Conversation', convoSchema);
@@ -65,13 +75,14 @@ const getConvo = async (user, conversationId) => {
};
module.exports = {
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
update.user = user;
}
if (newConversationId) {
update.conversationId = newConversationId;
@@ -97,9 +108,13 @@ module.exports = {
},
updateConvo: async (user, { conversationId, ...update }) => {
try {
return await Conversation.findOneAndUpdate({ conversationId: conversationId, user }, 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' };
@@ -121,74 +136,79 @@ module.exports = {
return { message: 'Error getting conversations' };
}
},
getConvosQueried: async (user, convoIds, pageNumber = 1, pageSize = 12) => {
try {
if (!convoIds || convoIds.length === 0) {
return { conversations: [], pages: 1, pageNumber, pageSize };
}
const cache = {};
const promises = [];
// will handle a syncing solution soon
const deletedConvoIds = [];
convoIds.forEach((convo) =>
promises.push(
Conversation.findOne({
user,
conversationId: convo.conversationId
}).exec()
)
);
const results = (await Promise.all(promises)).filter((convo, i) => {
if (!convo) {
deletedConvoIds.push(convoIds[i].conversationId);
return false;
} else {
const page = Math.floor(i / pageSize) + 1;
if (!cache[page]) {
cache[page] = [];
}
cache[page].push(convo);
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),
};
} catch (error) {
console.log(error);
return { message: 'Error fetching conversations' };
}
},
getConvo,
/* chore: this method is not properly error handled */
getConvoTitle: async (user, conversationId) => {
try {
const convo = await getConvo(user, conversationId);
return convo.title;
/* 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 { message: 'Error getting conversation title' };
return 'Error getting conversation title';
}
},
deleteConvos: async (user, filter) => {
let deleteCount = await Conversation.deleteMany({...filter, user}).exec();
let deleteCount = await Conversation.deleteMany({ ...filter, user }).exec();
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

@@ -1,14 +1,18 @@
const mongoose = require('mongoose');
const mongoMeili = require('../lib/db/mongoMeili');
const messageSchema = mongoose.Schema({
messageId: {
type: String,
unique: true,
required: true
required: true,
index: true,
meiliIndex: true
},
conversationId: {
type: String,
required: true
required: true,
meiliIndex: true
},
conversationSignature: {
type: String,
@@ -26,11 +30,13 @@ const messageSchema = mongoose.Schema({
},
sender: {
type: String,
required: true
required: true,
meiliIndex: true
},
text: {
type: String,
required: true
required: true,
meiliIndex: true
},
isCreatedByUser: {
type: Boolean,
@@ -41,11 +47,26 @@ const messageSchema = mongoose.Schema({
type: Boolean,
default: false
},
_meiliIndex: {
type: Boolean,
required: false,
select: false,
default: false
}
}, { timestamps: true });
// messageSchema.plugin(mongoMeili, {
// host: process.env.MEILI_HOST,
// apiKey: process.env.MEILI_KEY,
// indexName: 'messages', // Will get created automatically if it doesn't exist already
// primaryKey: 'messageId',
// });
const Message = mongoose.models.Message || mongoose.model('Message', messageSchema);
module.exports = {
messageSchema,
Message,
saveMessage: async ({ messageId, conversationId, parentMessageId, sender, text, isCreatedByUser=false, error }) => {
try {
await Message.findOneAndUpdate({ messageId }, {
@@ -62,6 +83,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,

2543
api/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -21,19 +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

@@ -1,7 +1,7 @@
const express = require('express');
const session = require('express-session')
const dbConnect = require('../models/dbConnect');
const { migrateDb } = require('../models');
const connectDb = require('../lib/db/connectDb');
const migrateDb = require('../lib/db/migrateDb');
const path = require('path');
const cors = require('cors');
const routes = require('./routes');
@@ -10,7 +10,7 @@ const port = process.env.PORT || 3080;
const host = process.env.HOST || 'localhost'
const userSystemEnabled = process.env.ENABLE_USER_SYSTEM || false
const projectPath = path.join(__dirname, '..', '..', 'client');
dbConnect().then(() => {
connectDb().then(() => {
console.log('Connected to MongoDB');
migrateDb();
});
@@ -25,10 +25,12 @@ app.use(session({
saveUninitialized: true,
}))
app.get('/', routes.authenticatedOrRedirect, function (req, res) {
console.log(path.join(projectPath, 'public', 'index.html'));
res.sendFile(path.join(projectPath, 'public', 'index.html'));
});
/* 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) {
@@ -43,6 +45,7 @@ app.get('/api/me', function (req, res) {
}
});
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);

View File

@@ -17,7 +17,9 @@ router.post('/', async (req, res) => {
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,10 +38,15 @@ router.post('/', async (req, res) => {
...convo
});
// Chore: This creates a loose a stranded initial message for chatgptBrowser
if (!overrideParentMessageId) {
await saveMessage(userMessage);
await saveConvo(req?.session?.user?.username, { ...userMessage, model, ...convo });
}
if (!overrideParentMessageId && model !== 'chatgptBrowser') {
await saveConvo(req?.session?.user?.username, { ...userMessage, model, ...convo });
}
return await ask({
userMessage,
@@ -111,9 +118,9 @@ const ask = async ({
});
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
@@ -144,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;
}
@@ -155,6 +163,11 @@ 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(req?.session?.user?.username, gptResponse);
sendMessage(res, {
@@ -171,7 +184,8 @@ const ask = async ({
await saveConvo(
req?.session?.user?.username,
{
conversationId,
/* again, for sake of browser client, will soon refactor */
conversationId: model === 'chatgptBrowser' ? gptResponse.conversationId : conversationId,
title
}
);

View File

@@ -2,7 +2,7 @@ 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) => {
@@ -21,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,
@@ -34,13 +34,13 @@ router.post('/', async (req, res) => {
console.log('ask log', {
model,
...userMessage,
...convo
...convo,
...userMessage
});
if (!overrideParentMessageId) {
await saveMessage(userMessage);
await saveConvo(req?.session?.user?.username, { ...userMessage, model, ...convo });
await saveBingMessage(userMessage);
await saveConvo(req?.session?.user?.username, { model, ...convo, ...userMessage });
}
return await ask({
@@ -114,8 +114,9 @@ const ask = async ({
convo.conversationSignature || response.conversationSignature;
userMessage.conversationId = response.conversationId || conversationId;
userMessage.invocationId = response.invocationId;
userMessage.messageId = response.details.requestId || userMessageId;
if (!overrideParentMessageId)
await saveMessage(userMessage);
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.
@@ -140,13 +141,14 @@ 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(req?.session?.user?.username, { ...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(req?.session?.user?.username, conversationId),
@@ -178,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,7 +2,7 @@ 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) => {
@@ -21,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,
@@ -34,13 +34,13 @@ router.post('/', async (req, res) => {
console.log('ask log', {
model,
...userMessage,
...convo
...convo,
...userMessage
});
if (!overrideParentMessageId) {
await saveMessage(userMessage);
await saveConvo(req?.session?.user?.username, { ...userMessage, model, ...convo });
await saveBingMessage(userMessage);
await saveConvo(req?.session?.user?.username, { model, ...convo, ...userMessage });
}
return await ask({
@@ -100,9 +100,9 @@ const ask = async ({
parentMessageId: overrideParentMessageId || userMessageId
}),
convo: {
...convo,
parentMessageId: userParentMessageId,
conversationId,
...convo
conversationId
},
abortController
});
@@ -114,9 +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.
if (!overrideParentMessageId)
await saveMessage(userMessage);
await saveBingMessage({ oldMessageId: userMessageId, ...userMessage });
// Save sydney response
// response.id = response.messageId;
@@ -140,7 +141,7 @@ const ask = async ({
// Save user message
userMessage.conversationId = response.conversationId || conversationId;
if (!overrideParentMessageId)
await saveMessage(userMessage);
await saveBingMessage(userMessage);
// Bing API will not use our conversationId at the first time,
// so change the placeholder conversationId to the real one.
@@ -158,8 +159,8 @@ const ask = async ({
response.text = await handleText(response, true);
// Save sydney response & convo, then send
await saveMessage(response);
await saveConvo(req?.session?.user?.username, { ...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(req?.session?.user?.username, conversationId),
@@ -191,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;

View File

@@ -10,6 +10,12 @@ router.get('/', async (req, res) => {
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;

View File

@@ -1,6 +1,9 @@
const _ = require('lodash');
const citationRegex = /\[\^\d+?\^]/g;
const { getCitations, citeText, detectCode } = require('../../app/');
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`);
@@ -16,12 +19,43 @@ 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/, '');
}
@@ -30,7 +64,7 @@ const createOnProgress = () => {
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++;
};

View File

@@ -2,7 +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, auth, authenticatedOr401, authenticatedOrRedirect };
module.exports = { search, ask, messages, convos, customGpts, prompts, auth, authenticatedOr401, authenticatedOrRedirect };

View File

@@ -0,0 +1,81 @@
const express = require('express');
const router = express.Router();
const { Message } = require('../../models/Message');
const { Conversation, getConvosQueried } = require('../../models/Conversation');
const { reduceMessages, reduceHits } = require('../../lib/utils/reduceHits');
// const { MeiliSearch } = require('meilisearch');
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 } = cached;
res.status(200).send({ conversations: cached[pageNumber], pages, pageNumber, pageSize });
return;
} else {
cache.clear();
}
// const message = await Message.meiliSearch(q);
const messages = (
await Message.meiliSearch(
q,
{
attributesToHighlight: ['text'],
highlightPreTag: '**',
highlightPostTag: '**'
},
true
)
).hits.map((message) => {
const { _formatted, ...rest } = message;
return {
...rest,
searchResult: true,
text: _formatted.text
};
});
const titles = (await Conversation.meiliSearch(q)).hits;
const sortedHits = reduceHits(messages, titles);
const result = await getConvosQueried(user, sortedHits, pageNumber);
cache.set(q, result.cache);
delete result.cache;
result.messages = messages.filter((message) => !result.filter.has(message.conversationId));
// 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);
});
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'],
}
}

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 .

2651
client/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -30,17 +30,27 @@
"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",
"remark-gfm": "^3.0.1",
"remark-math": "^5.1.1",
"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

@@ -15,29 +15,27 @@ const App = () => {
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)
useDocumentTitle(title);
useEffect(() => {
axios.get('/api/me', {
timeout: 1000,
withCredentials: true
}).then((res) => {
return res.data
}).then((user) => {
if (user)
dispatch(setUser(user))
else {
console.log('Not login!')
window.location.href = "/auth/login";
useEffect(async () => {
try {
const response = await axios.get('/api/me', {
timeout: 1000,
withCredentials: true
});
const user = response.data;
if (user) {
dispatch(setUser(user));
} else {
console.log('Not login!');
window.location.href = '/auth/login';
}
}).catch((error) => {
console.error(error)
console.log('Not login!')
window.location.href = "/auth/login";
})
// setUser
} catch (error) {
console.error(error);
console.log('Not login!');
window.location.href = '/auth/login';
}
}, [])
if (user)
@@ -47,7 +45,7 @@ const App = () => {
<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 ? (
{messages.length === 0 && title.toLowerCase() === 'chatgpt clone' ? (
<Landing title={title} />
) : (
<Messages

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

@@ -17,7 +17,7 @@ import {
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';
@@ -160,12 +160,12 @@ export default function TextChat({ messages }) {
} else if (model === 'bingai') {
console.log('Bing data:', data);
const { title } = data;
const { conversationSignature, clientId, conversationId, invocationId } =
const { conversationSignature, clientId, conversationId, invocationId, parentMessageId } =
responseMessage;
dispatch(
setConversation({
title,
parentMessageId: null,
parentMessageId,
conversationSignature,
clientId,
conversationId,
@@ -238,6 +238,7 @@ export default function TextChat({ messages }) {
if (data.final) {
convoHandler(data, currentState, currentMsg);
dispatch(toggleCursor());
console.log('final', data);
}
if (data.created) {
@@ -247,6 +248,7 @@ export default function TextChat({ messages }) {
let text = data.text || data.response;
if (data.initial) {
console.log(data);
dispatch(toggleCursor());
}
if (data.message) {
latestResponseText = text;
@@ -268,6 +270,7 @@ export default function TextChat({ messages }) {
events.onmessage = onMessage;
events.oncancel = (e) => {
dispatch(toggleCursor(true));
cancelHandler(latestResponseText, currentState, currentMsg);
};
@@ -276,7 +279,7 @@ export default function TextChat({ messages }) {
events.close();
const data = JSON.parse(e.data);
dispatch(toggleCursor(true));
errorHandler(data, currentState, currentMsg);
};
@@ -284,6 +287,7 @@ export default function TextChat({ messages }) {
return () => {
events.removeEventListener('message', onMessage);
dispatch(toggleCursor(true));
const isCancelled = events.readyState <= 1;
events.close();
if (isCancelled) {
@@ -343,12 +347,29 @@ export default function TextChat({ messages }) {
dispatch(setError(false));
};
const isSearchView = messages?.[0]?.searchResult === true;
const getPlaceholderText = () => {
if (isSearchView) {
return 'Click a message 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="input-panel md:bg-vert-light-gradient dark:md:bg-vert-dark-gradient fixed bottom-0 left-0 w-full border-t bg-white py-2 dark:border-white/20 dark:bg-gray-800 md:absolute md:border-t-0 md:border-transparent md:bg-transparent md:dark:border-transparent md:dark:bg-transparent">
<form className="stretch mx-2 flex flex-row gap-3 last:mb-2 md:pt-2 md:last:mb-6 lg:mx-auto lg:max-w-3xl lg:pt-6">
<div className="relative flex h-full flex-1 md:flex-col">
<span className="order-last ml-1 flex justify-center gap-0 md:order-none md:m-auto md:mb-2 md:w-full md:gap-2">
{isSubmitting ? (
{isSubmitting && !isSearchView ? (
<button
onClick={handleStopGenerating}
className="input-panel-button btn btn-neutral flex justify-center gap-2 border-0 md:border"
@@ -357,7 +378,7 @@ export default function TextChat({ messages }) {
<StopGeneratingIcon />
<span className="hidden md:block">Stop generating</span>
</button>
) : latestMessage && !latestMessage?.isCreatedByUser ? (
) : latestMessage && !latestMessage?.isCreatedByUser && !isSearchView ? (
<button
onClick={handleRegenerate}
className="input-panel-button btn btn-neutral flex justify-center gap-2 border-0 md:border"
@@ -388,15 +409,9 @@ export default function TextChat({ messages }) {
onChange={changeHandler}
onCompositionStart={handleCompositionStart}
onCompositionEnd={handleCompositionEnd}
placeholder={
disabled
? 'Choose another model or customize GPT again'
: isNotAppendable
? 'Edit your message or Regenerate.'
: ''
}
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 focus:outline-none focus:ring-0 focus-visible:ring-0 dark:bg-transparent md:pl-8"
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"
/>
<SubmitButton
submitMessage={submitMessage}

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,85 @@
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 CodeBlock from './CodeBlock';
import { langSubset } from '~/utils/languages';
const Content = React.memo(({ content }) => {
return (
<>
<ReactMarkdown
remarkPlugins={[remarkGfm, [remarkMath, { singleDollarTextMath: false }]]}
rehypePlugins={[
[rehypeKatex, { output: 'mathml' }],
[
rehypeHighlight,
{
detect: true,
ignoreMissing: true,
subset: langSubset
}
]
]}
linkTarget="_new"
components={{
code,
p,
}}
>
{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 <p className="whitespace-pre-wrap ">{props?.children}</p>;
});
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,25 @@
import React from 'react';
import TextWrapper from './TextWrapper';
import Content from './Content';
const Wrapper = React.memo(({ text, generateCursor, isCreatedByUser, searchResult }) => {
if (!isCreatedByUser && searchResult) {
return (
<Content
content={text}
generateCursor={generateCursor}
/>
);
} else if (!isCreatedByUser && !searchResult) {
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

@@ -1,12 +1,15 @@
import React, { useState, useEffect, useRef, useCallback } from 'react';
import TextWrapper from './TextWrapper';
import Wrapper from './Content/Wrapper';
import MultiMessage from './MultiMessage';
import { useSelector, useDispatch } from 'react-redux';
import HoverButtons from './HoverButtons';
import SiblingSwitch from './SiblingSwitch';
import { setConversation, setLatestMessage } from '../../store/convoSlice';
import { getIconOfModel } from '../../utils';
import { useMessageHandler } from '../../utils/handleSubmit';
import { setConversation, setLatestMessage } from '~/store/convoSlice';
import { setModel, setCustomModel, setCustomGpt, toggleCursor, setDisabled } from '~/store/submitSlice';
import { setMessages } from '~/store/messageSlice';
import { fetchById } from '~/utils/fetchers';
import { getIconOfModel } from '~/utils';
import { useMessageHandler } from '~/utils/handleSubmit';
export default function Message({
message,
@@ -18,13 +21,13 @@ export default function Message({
siblingCount,
setSiblingIdx
}) {
const { isSubmitting, model, chatGptLabel, promptPrefix } = useSelector(
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 { convos } = useSelector((state) => state.convo);
const last = !message?.children?.length;
const edit = message.messageId == currentEditId;
const { ask } = useMessageHandler();
@@ -38,8 +41,12 @@ export default function Message({
return '';
}
if (!cursor) {
return '';
}
return <span className="result-streaming"></span>;
}, [blinker]);
}, [blinker, cursor]);
useEffect(() => {
if (blinker && !abortScroll) {
@@ -74,6 +81,7 @@ export default function Message({
sender,
isCreatedByUser,
model,
searchResult,
chatGptLabel,
promptPrefix,
error
@@ -83,6 +91,10 @@ 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]';
if (message.bg && searchResult) {
props.className = message.bg + ' cursor-pointer';
}
// const wrapText = (text) => <TextWrapper text={text} generateCursor={generateCursor}/>;
const resubmitMessage = () => {
@@ -98,11 +110,32 @@ export default function Message({
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">
@@ -159,21 +192,19 @@ 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">

View File

@@ -6,7 +6,7 @@ export default function MultiMessage({
messages,
scrollToBottom,
currentEditId,
setCurrentEditId
setCurrentEditId,
}) {
const [siblingIdx, setSiblingIdx] = useState(0);
@@ -29,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,5 +1,6 @@
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';
@@ -34,10 +35,11 @@ export default function Messages({ messages, messageTree }) {
};
}, [messages]);
const scrollToBottom = useCallback(() => {
const scrollToBottom = useCallback(throttle(() => {
console.log('scrollToBottom');
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
setShowScrollButton(false);
}, [messagesEndRef]);
}, 750, { leading: true }), [messagesEndRef]);
const handleScroll = () => {
const { scrollTop, scrollHeight, clientHeight } = scrollableRef.current;
@@ -73,7 +75,7 @@ export default function Messages({ messages, messageTree }) {
<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 ? (
{(messageTree.length === 0) ? (
<Spinner />
) : (
<>

View File

@@ -1,13 +1,15 @@
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';
export default function NavLinks() {
export default function NavLinks({ fetch, onSearchSuccess, clearSearch }) {
return (
<>
{/* <SearchBar fetch={fetch} onSuccess={onSearchSuccess} clearSearch={clearSearch}/> */}
<ClearConvos />
<DarkMode />
<Logout />

View File

@@ -0,0 +1,60 @@
import React, { useState, useCallback } from 'react';
import { debounce } from 'lodash';
import { useDispatch } from 'react-redux';
import { Search } from 'lucide-react';
import { setQuery } from '~/store/searchSlice';
export default function SearchBar({ fetch, clearSearch }) {
const dispatch = useDispatch();
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;
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,66 @@
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) {
dispatch(setMessages(res.messages));
dispatch(setDisabled(true));
}
};
const fetch = useCallback(_.partialRight(searchFetcher.bind(null, () => setIsFetching(true)), onSearchSuccess), [dispatch]);
const clearSearch = () => {
setPage(1);
dispatch(refreshConversation());
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 +76,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 +148,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

@@ -15,8 +15,9 @@ const initialState = {
pageNumber: 1,
pages: 1,
refreshConvoHint: 0,
search: false,
latestMessage: null,
convos: [],
convos: []
};
const currentSlice = createSlice({
@@ -27,7 +28,13 @@ const currentSlice = createSlice({
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;
@@ -56,9 +63,12 @@ const currentSlice = createSlice({
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));
}
},
setPages: (state, action) => {
state.pages = action.payload;
@@ -71,11 +81,23 @@ const currentSlice = createSlice({
},
setLatestMessage: (state, action) => {
state.latestMessage = action.payload;
},
}
}
});
export const { refreshConversation, setConversation, setPages, setConvos, setNewConvo, setError, increasePage, decreasePage, setPage, removeConvo, removeAll, setLatestMessage } =
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,11 +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 userReducer from './userReducer.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: {
@@ -15,6 +16,7 @@ export const store = configureStore({
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

@@ -0,0 +1,30 @@
import { createSlice } from '@reduxjs/toolkit';
const initialState = {
search: false,
query: '',
};
const currentSlice = createSlice({
name: 'search',
initialState,
reducers: {
setSearchState: (state, action) => {
state.search = 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 { setSearchState, setQuery } = currentSlice.actions;
export default currentSlice.reducer;

View File

@@ -9,6 +9,7 @@ const initialState = {
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

@@ -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,33 @@
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-[#32343e] 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;
}
}

View File

@@ -3,21 +3,38 @@ import axios from 'axios';
import useSWR from 'swr';
import useSWRMutation from 'swr/mutation';
const fetcher = (url) => fetch(url, {credentials: 'include'}).then((res) => res.json());
const fetcher = (url) => fetch(url, { credentials: 'include' }).then((res) => res.json());
const axiosFetcher = async (url, params) => {
console.log(params, 'params');
return axios.get(url, params);
};
const postRequest = async (url, { arg }) => {
return await axios.post(url, { withCredentials: true, arg });
};
export const searchFetcher = async (pre, q, pageNumber, callback) => {
pre();
const { data } = await axios.get(`/api/search?q=${q}&pageNumber=${pageNumber}`);
console.log('search data', data);
callback(data);
};
export const fetchById = async (path, conversationId) => {
return await axios.get(`/api/${path}/${conversationId}`);
// console.log(`fetch ${path} data`, data);
// callback(data);
};
export const swr = (path, successCallback, options) => {
const _options = {...options};
const _options = { ...options };
if (successCallback) {
_options.onSuccess = successCallback;
}
return useSWR(path, fetcher, _options);
}
};
export default function manualSWR(path, type, successCallback) {
const options = {};
@@ -28,3 +45,16 @@ export default function manualSWR(path, type, successCallback) {
const fetchFunction = type === 'get' ? fetcher : postRequest;
return useSWRMutation(path, fetchFunction, options);
}
export function useManualSWR({ path, params, type, onSuccess }) {
const options = {};
if (onSuccess) {
options.onSuccess = onSuccess;
}
console.log(params, 'params');
const fetchFunction = type === 'get' ? _.partialRight(axiosFetcher, params) : postRequest;
return useSWRMutation(path, fetchFunction, options);
}

View File

@@ -6,6 +6,7 @@ import { setMessages } from '~/store/messageSlice';
import { setSubmitState, setSubmission } from '~/store/submitSlice';
import { setText } from '~/store/textSlice';
import { setError } from '~/store/convoSlice';
import {v4} from 'uuid';
const useMessageHandler = () => {
const dispatch = useDispatch();
@@ -27,7 +28,7 @@ const useMessageHandler = () => {
// this is not a real messageId, it is used as placeholder before real messageId returned
text = text.trim();
const fakeMessageId = crypto.randomUUID();
const fakeMessageId = v4();
const isCustomModel = model === 'chatgptCustom' || !initial[model];
const sender = model === 'chatgptCustom' ? chatGptLabel : model;
parentMessageId = parentMessageId || latestMessage?.messageId || '00000000-0000-0000-0000-000000000000';
@@ -161,4 +162,4 @@ export default function handleSubmit({
});
events.stream();
}
}

View File

@@ -46,7 +46,9 @@ export const wrapperRegex = {
newLineMatch: /^```(\n+)/
};
export const getIconOfModel = ({ size=30, sender, isCreatedByUser, model, chatGptLabel, error, ...props }) => {
export const getIconOfModel = ({ size=30, sender, isCreatedByUser, searchResult, model, chatGptLabel, error, ...props }) => {
// 'ai' is used as 'model' is not accurate for search results
let ai = searchResult ? sender : model;
const { button } = props;
const bgColors = {
chatgpt: `rgb(16, 163, 127${ button ? ', 0.75' : ''})`,
@@ -69,12 +71,12 @@ export const getIconOfModel = ({ size=30, sender, isCreatedByUser, model, chatGp
else if (!isCreatedByUser) {
// TODO: use model from convo, rather than submit
// const { model, chatGptLabel, promptPrefix } = convo;
let background = bgColors[model];
const isBing = model === 'bingai' || model === 'sydney';
let background = bgColors[ai];
const isBing = ai === 'bingai' || ai === 'sydney';
return (
<div
title={chatGptLabel || model}
title={chatGptLabel || ai}
style={
{ background: background || 'radial-gradient(circle at 90% 110%, #F0F0FA, #D0E0F9)', width: size, height: size }
}

View File

@@ -315,4 +315,42 @@ const languages = new Set([
'zephir',
]);
module.exports = languages;
const langSubset = [
'python',
'javascript',
'java',
'go',
'bash',
'c',
'cpp',
'csharp',
'css',
'diff',
'graphql',
'json',
'kotlin',
'less',
'lua',
'makefile',
'markdown',
'objectivec',
'perl',
'php',
'php-template',
'plaintext',
'python-repl',
'r',
'ruby',
'rust',
'scss',
'shell',
'sql',
'swift',
'typescript',
'vbnet',
'wasm',
'xml',
'yaml',
];
module.exports = { languages, langSubset };

View File

@@ -1,50 +1,42 @@
version: "2"
version: "3.4"
services:
client:
image: react-client
build: ./client
restart: always
ports:
- "3080:80"
volumes:
- ./client:/client
- /client/node_modules
links:
- api
networks:
- webappnetwork
api:
image: node-api
build: ./api
restart: always
env_file:
- ./api/.env
environment:
- HOST=0.0.0.0
- NODE_ENV=production
- MONGO_URI=mongodb://mongodb:27017/chatgpt-clone
ports:
- "9000:3080"
volumes:
- ./api:/api
- /api/node_modules
depends_on:
- mongodb
networks:
- webappnetwork
mongodb:
image: mongo
restart: always
container_name: mongodb
volumes:
- ./data-node:/data/db
ports:
- 27020:27017
command: mongod --noauth
networks:
- webappnetwork
networks:
webappnetwork:
driver: bridge
# client:
# image: nginx-client
# build:
# context: .
# target: nginx-client
# restart: always
# ports:
# - 3080:80
# volumes:
# - /client/node_modules
# depends_on:
# - api
api:
ports:
- 3080:3080 # Change it to 9000:3080 if you want to use nginx
depends_on:
- mongodb
image: node-api
build:
context: .
target: node-api
restart: always
env_file:
- ./api/.env
environment:
- HOST=0.0.0.0
- NODE_ENV=production
- MONGO_URI=mongodb://mongodb:27017/chatgpt-clone
volumes:
- /client/node_modules
- ./api:/api
- /api/node_modules
mongodb:
image: mongo
restart: always
container_name: mongodb
volumes:
- ./data-node:/data/db
command: mongod --noauth

10
meilisearch.yml Normal file
View File

@@ -0,0 +1,10 @@
version: '3'
services:
meilisearch:
image: getmeili/meilisearch:v1.0
ports:
- 7700:7700
environment:
- MEILI_MASTER_KEY=MASTER_KEY
volumes:
- ./meili_data:/meili_data

293
package-lock.json generated
View File

@@ -1,293 +0,0 @@
{
"name": "chatgpt-clone",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"dependencies": {
"sanitize-html": "^2.10.0"
}
},
"node_modules/deepmerge": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.0.tgz",
"integrity": "sha512-z2wJZXrmeHdvYJp/Ux55wIjqo81G5Bp4c+oELTW+7ar6SogWHajt5a9gO3s3IDaGSAXjDk0vlQKN3rms8ab3og==",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/dom-serializer": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
"integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
"dependencies": {
"domelementtype": "^2.3.0",
"domhandler": "^5.0.2",
"entities": "^4.2.0"
},
"funding": {
"url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
}
},
"node_modules/domelementtype": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
"integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fb55"
}
]
},
"node_modules/domhandler": {
"version": "5.0.3",
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
"integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
"dependencies": {
"domelementtype": "^2.3.0"
},
"engines": {
"node": ">= 4"
},
"funding": {
"url": "https://github.com/fb55/domhandler?sponsor=1"
}
},
"node_modules/domutils": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/domutils/-/domutils-3.0.1.tgz",
"integrity": "sha512-z08c1l761iKhDFtfXO04C7kTdPBLi41zwOZl00WS8b5eiaebNpY00HKbztwBq+e3vyqWNwWF3mP9YLUeqIrF+Q==",
"dependencies": {
"dom-serializer": "^2.0.0",
"domelementtype": "^2.3.0",
"domhandler": "^5.0.1"
},
"funding": {
"url": "https://github.com/fb55/domutils?sponsor=1"
}
},
"node_modules/entities": {
"version": "4.4.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-4.4.0.tgz",
"integrity": "sha512-oYp7156SP8LkeGD0GF85ad1X9Ai79WtRsZ2gxJqtBuzH+98YUV6jkHEKlZkMbcrjJjIVJNIDP/3WL9wQkoPbWA==",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/escape-string-regexp": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
"integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/htmlparser2": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.1.tgz",
"integrity": "sha512-4lVbmc1diZC7GUJQtRQ5yBAeUCL1exyMwmForWkRLnwyzWBFxN633SALPMGYaWZvKe9j1pRZJpauvmxENSp/EA==",
"funding": [
"https://github.com/fb55/htmlparser2?sponsor=1",
{
"type": "github",
"url": "https://github.com/sponsors/fb55"
}
],
"dependencies": {
"domelementtype": "^2.3.0",
"domhandler": "^5.0.2",
"domutils": "^3.0.1",
"entities": "^4.3.0"
}
},
"node_modules/is-plain-object": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz",
"integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/nanoid": {
"version": "3.3.4",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.4.tgz",
"integrity": "sha512-MqBkQh/OHTS2egovRtLk45wEyNXwF+cokD+1YPf9u5VfJiRdAiRwB2froX5Co9Rh20xs4siNPm8naNotSD6RBw==",
"bin": {
"nanoid": "bin/nanoid.cjs"
},
"engines": {
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/parse-srcset": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/parse-srcset/-/parse-srcset-1.0.2.tgz",
"integrity": "sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q=="
},
"node_modules/picocolors": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz",
"integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ=="
},
"node_modules/postcss": {
"version": "8.4.21",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.21.tgz",
"integrity": "sha512-tP7u/Sn/dVxK2NnruI4H9BG+x+Wxz6oeZ1cJ8P6G/PZY0IKk4k/63TDsQf2kQq3+qoJeLm2kIBUNlZe3zgb4Zg==",
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/postcss/"
},
{
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/postcss"
}
],
"dependencies": {
"nanoid": "^3.3.4",
"picocolors": "^1.0.0",
"source-map-js": "^1.0.2"
},
"engines": {
"node": "^10 || ^12 || >=14"
}
},
"node_modules/sanitize-html": {
"version": "2.10.0",
"resolved": "https://registry.npmjs.org/sanitize-html/-/sanitize-html-2.10.0.tgz",
"integrity": "sha512-JqdovUd81dG4k87vZt6uA6YhDfWkUGruUu/aPmXLxXi45gZExnt9Bnw/qeQU8oGf82vPyaE0vO4aH0PbobB9JQ==",
"dependencies": {
"deepmerge": "^4.2.2",
"escape-string-regexp": "^4.0.0",
"htmlparser2": "^8.0.0",
"is-plain-object": "^5.0.0",
"parse-srcset": "^1.0.2",
"postcss": "^8.3.11"
}
},
"node_modules/source-map-js": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.2.tgz",
"integrity": "sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==",
"engines": {
"node": ">=0.10.0"
}
}
},
"dependencies": {
"deepmerge": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.0.tgz",
"integrity": "sha512-z2wJZXrmeHdvYJp/Ux55wIjqo81G5Bp4c+oELTW+7ar6SogWHajt5a9gO3s3IDaGSAXjDk0vlQKN3rms8ab3og=="
},
"dom-serializer": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
"integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
"requires": {
"domelementtype": "^2.3.0",
"domhandler": "^5.0.2",
"entities": "^4.2.0"
}
},
"domelementtype": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
"integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw=="
},
"domhandler": {
"version": "5.0.3",
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
"integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
"requires": {
"domelementtype": "^2.3.0"
}
},
"domutils": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/domutils/-/domutils-3.0.1.tgz",
"integrity": "sha512-z08c1l761iKhDFtfXO04C7kTdPBLi41zwOZl00WS8b5eiaebNpY00HKbztwBq+e3vyqWNwWF3mP9YLUeqIrF+Q==",
"requires": {
"dom-serializer": "^2.0.0",
"domelementtype": "^2.3.0",
"domhandler": "^5.0.1"
}
},
"entities": {
"version": "4.4.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-4.4.0.tgz",
"integrity": "sha512-oYp7156SP8LkeGD0GF85ad1X9Ai79WtRsZ2gxJqtBuzH+98YUV6jkHEKlZkMbcrjJjIVJNIDP/3WL9wQkoPbWA=="
},
"escape-string-regexp": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
"integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="
},
"htmlparser2": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.1.tgz",
"integrity": "sha512-4lVbmc1diZC7GUJQtRQ5yBAeUCL1exyMwmForWkRLnwyzWBFxN633SALPMGYaWZvKe9j1pRZJpauvmxENSp/EA==",
"requires": {
"domelementtype": "^2.3.0",
"domhandler": "^5.0.2",
"domutils": "^3.0.1",
"entities": "^4.3.0"
}
},
"is-plain-object": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz",
"integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q=="
},
"nanoid": {
"version": "3.3.4",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.4.tgz",
"integrity": "sha512-MqBkQh/OHTS2egovRtLk45wEyNXwF+cokD+1YPf9u5VfJiRdAiRwB2froX5Co9Rh20xs4siNPm8naNotSD6RBw=="
},
"parse-srcset": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/parse-srcset/-/parse-srcset-1.0.2.tgz",
"integrity": "sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q=="
},
"picocolors": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz",
"integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ=="
},
"postcss": {
"version": "8.4.21",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.21.tgz",
"integrity": "sha512-tP7u/Sn/dVxK2NnruI4H9BG+x+Wxz6oeZ1cJ8P6G/PZY0IKk4k/63TDsQf2kQq3+qoJeLm2kIBUNlZe3zgb4Zg==",
"requires": {
"nanoid": "^3.3.4",
"picocolors": "^1.0.0",
"source-map-js": "^1.0.2"
}
},
"sanitize-html": {
"version": "2.10.0",
"resolved": "https://registry.npmjs.org/sanitize-html/-/sanitize-html-2.10.0.tgz",
"integrity": "sha512-JqdovUd81dG4k87vZt6uA6YhDfWkUGruUu/aPmXLxXi45gZExnt9Bnw/qeQU8oGf82vPyaE0vO4aH0PbobB9JQ==",
"requires": {
"deepmerge": "^4.2.2",
"escape-string-regexp": "^4.0.0",
"htmlparser2": "^8.0.0",
"is-plain-object": "^5.0.0",
"parse-srcset": "^1.0.2",
"postcss": "^8.3.11"
}
},
"source-map-js": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.2.tgz",
"integrity": "sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw=="
}
}
}

View File

@@ -1,5 +0,0 @@
{
"dependencies": {
"sanitize-html": "^2.10.0"
}
}