Compare commits

..

167 Commits

Author SHA1 Message Date
Wong Saang
9fe7943152 Merge pull request #157 from CheaterScript/main
feat: sinicize signup and signin pages.
2023-04-17 09:47:45 +08:00
AI&I
534ecb132c feat: 更新邀请码功能 2023-04-17 04:37:54 +08:00
AI&I
04fa7394f6 feat: add invitation code. 2023-04-17 04:07:04 +08:00
AI&I
b9ed2fd785 feat: add default language config. 2023-04-17 03:52:29 +08:00
AI&I
e24a99481e feat: sinicize signup and signin pages. 2023-04-17 03:30:22 +08:00
AI&I
405a4582b5 feat: add language setting in signin. 2023-04-17 03:05:58 +08:00
Rafi
09f470111e feat(shell): add pull command before up 2023-04-15 20:57:55 +08:00
Rafi
ba604e8389 docs: modify API key configuration instructions. 2023-04-15 13:31:29 +08:00
Rafi
d9caaefdef updated docker-compose.yml 2023-04-14 12:52:06 +08:00
Rafi
a3359be316 Merge remote-tracking branch 'origin/main' into main 2023-04-14 12:21:51 +08:00
Rafi
880b111001 feat(shell) Add DATABASE_URL to deployment.sh 2023-04-14 12:21:31 +08:00
Wong Saang
918b87979c Create FUNDING.yml 2023-04-12 18:05:14 +08:00
Rafi
385bcaf603 docs: updated workflow 2023-04-12 14:56:00 +08:00
Rafi
81e120ac47 docs: updated 2023-04-12 14:46:18 +08:00
Rafi
1320d69cfb docs: Adjust directory structure 2023-04-12 14:23:32 +08:00
Rafi
208376a418 update readme 2023-04-11 22:36:24 +08:00
Rafi
6e3a89468c Use yarn instead of pnpm to build the document. 2023-04-11 22:27:13 +08:00
Rafi
f953704831 Add document workflow. 2023-04-11 22:16:15 +08:00
Rafi
1d7098a0cb Use Vuepress to generate a static site for docs. 2023-04-11 22:08:16 +08:00
Rafi
e9f554dc4e Add WORKER_TIMEOUT info to readme 2023-04-11 17:51:06 +08:00
Rafi
55279def0d Add Dockerfile and workflow for static hosting image. 2023-04-11 15:32:06 +08:00
Rafi
fa14276d0a Fix: Resending message when the visibility of the browser page changes , which causes slowdown or failure to receive messages 2023-04-11 10:30:12 +08:00
Rafi
8718dc4ed1 using SERVER_DOMAIN at proxy target 2023-04-10 18:15:18 +08:00
Rafi
fe814acfd9 using http-proxy-middleware 2023-04-10 18:05:07 +08:00
Rafi
1e4f14c9b7 add user guide to readme 2023-04-07 19:39:14 +08:00
Rafi
137ca5ae1a Support Frugal Mode 2023-04-06 18:00:24 +08:00
Rafi
8a9b705b99 Fix the issue where useSettings does not work in SSR mode. 2023-04-06 16:07:46 +08:00
Rafi
82c1811034 Fix the issue of updating the number to a string type when clicking the plus or minus button in the input box when setting the model parameters. 2023-04-06 15:08:35 +08:00
Rafi
0d6aef6872 update readme 2023-04-06 10:54:36 +08:00
Rafi
3f3ab8c33b Fix the issue of unable to copy code blocks in new messages. 2023-04-06 10:17:15 +08:00
Rafi
6522536291 fix some known bugs 2023-04-06 09:56:26 +08:00
Rafi
2bca5a032c fix hydration text missing 2023-04-05 23:27:44 +08:00
Rafi
53460bd891 set default footer width 2023-04-05 23:20:31 +08:00
Rafi
fb9e8b8c7d api proxy 2023-04-05 23:17:14 +08:00
Rafi
21dc2b9236 fix Dockerfile 2023-04-04 19:43:20 +08:00
Rafi
1a6bf1d239 Improve the conversation process 2023-04-04 19:23:57 +08:00
Rafi
3e3283029d Improve the conversation process 2023-04-04 19:16:07 +08:00
Rafi
16c9b0e230 ... 2023-04-03 23:37:50 +08:00
Rafi
836df995d0 Improve login and authentication methods to adapt to the SSR mode. 2023-04-03 22:04:52 +08:00
Rafi
5b9d52b177 support ssr 2023-04-03 18:19:39 +08:00
Rafi
deb627a9ab Convert the MsgEditor component to a composite one 2023-04-02 15:43:31 +08:00
Rafi
70efc09dae Optimize the layout of message content. 2023-04-02 15:10:22 +08:00
Rafi
8ff914582a Fix the bug of conversation title 2023-04-01 21:54:15 +08:00
Rafi
f20a3562f3 Replace blank items in the conversation list with the default title 2023-04-01 21:22:44 +08:00
Rafi
4a1adf6d00 Add reset password function and display current user on the page. 2023-04-01 11:29:24 +08:00
Rafi
ddce1c9721 update readme 2023-03-31 17:37:10 +08:00
Rafi
f67ed7621c Multiple improvements for conversation 2023-03-30 21:45:23 +08:00
Rafi
97649e4bee pass 2023-03-30 18:12:55 +08:00
Rafi
1082da050b rm @vite-pwa/nuxt 2023-03-30 10:37:55 +08:00
Wong Saang
d89d1e288d Merge pull request #97 from erritis/main
Add title for prompt
2023-03-30 09:31:37 +08:00
Sergey Shekhovtsov
cd89d11d0b Add docker compose file for development
Added docker compose file for development

For convenience when developing
2023-03-28 22:36:53 +03:00
Erritis
cf0053a060 Add title for prompt
Added title for prompt

The title will simplify the search in the list of prompts
2023-03-28 22:35:12 +03:00
Wong Saang
019da4399e Merge pull request #93 from erritis/russian
Add Russian language support
2023-03-28 12:49:37 +08:00
Wong Saang
044961bb01 Merge pull request #90 from erritis/main
Add prompt fields to the translation section
2023-03-28 12:34:33 +08:00
Erritis
2374c81edb Add Russian language support 2023-03-28 04:02:56 +03:00
Erritis
699760713e Add prompt fields to the translation section
Added prompt fields to the translation section

Not all interface elements had the ability to add translation
2023-03-28 03:03:23 +03:00
Rafi
d75413cc49 update readme 2023-03-27 22:26:47 +08:00
Rafi
8175f199d2 Support GPT-4 2023-03-27 22:17:19 +08:00
Wong Saang
f8c2f396c1 Merge pull request #70 from Paramon/main
#50 add platform to docker-compose
2023-03-25 21:03:50 +08:00
Andrii Paramonov
8217647df8 #50 add platform 2023-03-24 16:44:00 +02:00
Rafi
288c9eeeca Change the functionality of custom API key to be controlled by the admin panel to determine whether it is enabled or not. 2023-03-24 15:43:01 +08:00
Rafi
4d09ff7c8a Added env var NUXT_PUBLIC_CUSTOM_API_KEY to docs 2023-03-24 14:32:38 +08:00
Rafi
5fa059017c Support controlling whether to enable the API Key setting module through environment variables. 2023-03-24 14:22:45 +08:00
Rafi
323f10844b Modify the placeholder of the default prompt for web search to solve the problem of not providing web search results to ChatGPT 2023-03-24 11:22:51 +08:00
Rafi
ee035390db add default prompt to web search 2023-03-23 18:48:08 +08:00
Wong Saang
be743bf799 Update README.md 2023-03-23 17:13:34 +08:00
Wong Saang
a59f84f2bf Update README.md 2023-03-23 17:12:59 +08:00
Rafi
ed0cf2997d update readme 2023-03-23 17:07:49 +08:00
Rafi
7f00c74097 Added wsgi-server environment variable EMAIL_FROM to docker-compose.yml and readme 2023-03-23 15:31:26 +08:00
Rafi
f007417fa4 add update info to readme 2023-03-23 12:53:08 +08:00
Rafi
27c5e2a3ac Get settings from backend, added web search functionality 2023-03-23 11:45:56 +08:00
Rafi
e90dc0c12b web_search toolbar 2023-03-22 23:29:58 +08:00
Rafi
837fd8c9ff update readme 2023-03-22 17:26:22 +08:00
Rafi
ce0b1004f3 Remove the parent_message_id constraint 2023-03-22 16:17:46 +08:00
Rafi
1ff1c46e37 Fix the bug of being unable to delete messages. 2023-03-22 15:55:06 +08:00
Rafi
afa3e499dc add DEBUT_PWA env variable 2023-03-22 14:12:49 +08:00
Rafi
70ce5746bc Merge remote-tracking branch 'origin/main' into main
# Conflicts:
#	nuxt.config.ts
#	yarn.lock
2023-03-22 13:50:53 +08:00
Rafi
35d4292d29 Import the @kevinmarrec/nuxt-pwa module to fix the related bugs of PWA feature. 2023-03-21 22:13:02 +08:00
Rafi
8bbc44e7bf update nuxt.config.ts 2023-03-21 18:48:35 +08:00
Rafi
3dcb4be6e4 add robots.txt 2023-03-21 18:06:44 +08:00
Rafi
83f8072625 mv @vite-pwa/nuxt to devDependencies 2023-03-21 13:46:02 +08:00
Rafi
3992121b71 update: docker-compose.yml 2023-03-21 10:20:08 +08:00
Rafi
d08806f0c9 update readme 2023-03-20 22:15:13 +08:00
Rafi
85ac73efcc Add email verification requirement judgment after completing registration 2023-03-20 22:03:53 +08:00
Rafi
7cc5a6b347 Fix: the language settings dialog not displaying the close button. 2023-03-20 20:13:23 +08:00
Rafi
983e4d436d update: deployment.sh 2023-03-20 12:54:11 +08:00
Rafi
727826f1b1 Added a Sign-out button 2023-03-19 14:26:46 +08:00
Rafi
386659109c Added a new message action: delete 2023-03-19 13:49:12 +08:00
Rafi
bd9e8bf45e Optimize the editor and enhance the user experience. 2023-03-19 13:39:20 +08:00
Rafi
4e40530a8c Added a new message action: edit 2023-03-19 13:13:27 +08:00
Rafi
ea69a350f4 add environment variable NUXT_DEV_SERVER 2023-03-19 12:53:44 +08:00
Rafi
18a4251714 feat: Message actions 2023-03-17 18:27:07 +08:00
Rafi
878fda0054 Support configuring model parameters in the front-end and storing them in localStorage. 2023-03-17 17:01:18 +08:00
Rafi
1f3a025918 feature: pwa 2023-03-17 12:36:24 +08:00
Rafi
f9db3e5866 update readme 2023-03-15 11:23:35 +08:00
Rafi
c9615ed05c catch error detail in signup page 2023-03-15 11:12:05 +08:00
Rafi
0d4b6247e2 docker-compose.yml includes restart: always in each service 2023-03-14 16:04:59 +08:00
Rafi
c9c3431cff Update deployment.sh 2023-03-14 13:00:23 +08:00
Rafi
46abf3daa0 Solve the problem of not clearing the messages on the right side when deleting the current conversation. 2023-03-10 11:21:58 +08:00
Rafi
8dcd7f46b1 Add 2 environment variables to control the typewriter effect: 2023-03-10 10:57:45 +08:00
Rafi
33d9c392fa Resolve the issue of missing indentation for "ol" tag in message content. 2023-03-10 10:17:26 +08:00
Rafi
bb17cdd123 Using markdown-it instead of marked as the markdown parser significantly improves the flickering issue during message rendering. 2023-03-09 23:35:56 +08:00
Rafi
4cfc9f4aea Temporarily disable the typewriter effect and improve the highlight method 2023-03-09 23:03:20 +08:00
Rafi
cd50086c1e update readme 2023-03-09 18:24:55 +08:00
Rafi
7e5498f779 update demog 2023-03-09 18:23:26 +08:00
Rafi
d933236a5d update demog 2023-03-09 18:23:11 +08:00
Rafi
0be2d45cd5 update demo 2023-03-09 18:22:09 +08:00
Rafi
e24ad26d99 update demo 2023-03-09 18:21:59 +08:00
Rafi
052f5299a0 Add frequently used prompt function. 2023-03-09 17:39:45 +08:00
Rafi
8340edbf40 Add typewriter effect to the messages of the model. 2023-03-09 15:05:40 +08:00
Rafi
7bff84638e update demo.png 2023-03-08 16:43:32 +08:00
Rafi
54660706e3 update demo.png 2023-03-08 16:41:58 +08:00
Rafi
a8acfeea58 Improve drawer width and conversation list style. 2023-03-08 16:22:22 +08:00
Rafi
85fc57e2b2 Display code language in code block, add code copy function 2023-03-08 16:09:34 +08:00
Rafi
fe4740b7a2 update layout 2023-03-08 11:33:23 +08:00
Rafi
2210dfcb98 update layout 2023-03-08 11:26:21 +08:00
Rafi
19794016fd update readme 2023-03-08 10:55:25 +08:00
Rafi
ce348c0f38 update the translation 2023-03-08 10:42:28 +08:00
Rafi
f251b16afe Add the 'clearable' attribute to the input on the signin page. Add the 'type' attribute to the password input and add a button to show/hide the password. 2023-03-08 10:37:00 +08:00
Rafi
4f32ef69b2 Add Chinese Readme 2023-03-07 21:21:06 +08:00
Rafi
e354a9490f Add Chinese Readme 2023-03-07 21:15:59 +08:00
Rafi
3d2c041cc2 update demo 2023-03-07 18:17:19 +08:00
Rafi
17588443e6 update readme 2023-03-07 18:02:11 +08:00
Rafi
298d7c1bda update readme 2023-03-07 17:48:18 +08:00
Rafi
8e27487cbb feat: Conversation editing and deletion 2023-03-07 17:39:49 +08:00
Rafi
a91f1b1348 update readme 2023-03-04 23:39:49 +08:00
Rafi
63b95c2ce2 update deployment.sh 2023-03-04 23:02:12 +08:00
Rafi
03512e8c7e Add a deployment script 2023-03-04 22:52:26 +08:00
Rafi
002db29717 Improved the style of the chat window, using message bubbles. 2023-03-04 20:25:52 +08:00
Rafi
6402f156dd Add the try_files directive to the nginx configuration 2023-03-04 14:45:29 +08:00
Rafi
a44ec5e2fb update readme 2023-03-04 00:26:16 +08:00
Rafi
32f3013337 update readme 2023-03-04 00:24:22 +08:00
Wong Saang
e66d994219 Merge pull request #12 from WongSaang/rest-auth
Support for the official ChatGPT model: gpt-3.5-turbo
2023-03-04 00:16:59 +08:00
Rafi
f166581a73 email verification 2023-03-03 18:50:02 +08:00
Rafi
ef6657187a account 2023-03-03 00:10:50 +08:00
Rafi
3b6c48a776 account 2023-03-02 23:10:56 +08:00
Rafi
b316ac0b4a update README.md 2023-03-01 16:37:56 +08:00
Rafi
51e8ea8d1a modify README.md 2023-03-01 09:17:34 +08:00
Rafi
60cd0689fb modify README.md 2023-02-28 23:02:30 +08:00
Rafi
74fc850ceb modify README.md 2023-02-28 22:53:50 +08:00
Rafi
339dd1e0c6 update nginx.conf 2023-02-28 16:43:02 +08:00
Rafi
122704737a update readme 2023-02-28 15:15:12 +08:00
Rafi
bd35c21e2f Delete the proxy of the admin path 2023-02-28 14:38:31 +08:00
Rafi
c2705e5f2a Update readme 2023-02-28 14:26:29 +08:00
Rafi
0e5aeddffa Update readme 2023-02-28 14:04:57 +08:00
Wong Saang
d9b1ece762 Merge pull request #7 from WongSaang/client-only
Front-end and Back-end Separation
2023-02-24 16:36:26 +08:00
Rafi
000e9f170f Update readme 2023-02-24 16:32:15 +08:00
Rafi
d96b5ad26a Improve the construction method 2023-02-24 14:48:39 +08:00
Rafi
03d7dc2589 hold conversations 2023-02-23 22:59:58 +08:00
Rafi
8685c8e87f feat: auth plugin 2023-02-23 18:36:04 +08:00
Rafi
49d634987d change api 2023-02-22 22:23:10 +08:00
Rafi
3e46512c15 feat: auth plugin 2023-02-22 16:50:53 +08:00
Rafi
eb7f062144 feat: auth plugin 2023-02-21 21:27:00 +08:00
Rafi
3c7d45154e remove server side 2023-02-21 14:13:22 +08:00
Rafi
13798e668a update demo 2023-02-16 18:19:28 +08:00
Wong Saang
d431048dc4 Merge pull request #5 from WongSaang/dev
feat: i18n
2023-02-16 17:55:03 +08:00
Rafi
9215965d45 feat: i18n
Add simplified Chinese translation
2023-02-16 17:34:40 +08:00
Rafi
66767d9352 feat: i18n config 2023-02-15 18:20:49 +08:00
Rafi
5abd5edba5 i18n config 2023-02-14 21:24:33 +08:00
Rafi
233eb9c27a feat: i18n 2023-02-14 18:32:49 +08:00
Rafi
5201349363 fix error when clicking the stop button and optimize SSE logic 2023-02-14 15:49:44 +08:00
Rafi
cdd8a86de0 Add feedback buttons 2023-02-13 21:21:47 +08:00
Rafi
96902c9e14 Modify the background color of the theme menu to white to solve the problem of not being able to see the menu 2023-02-13 21:12:46 +08:00
Rafi
b10fafd6a8 feat: Change the location of the snackbar to the top 2023-02-13 21:01:39 +08:00
Rafi
58e92bfe84 feat: Add a welcome screen 2023-02-13 20:55:50 +08:00
Rafi
efd1c96852 Add license to package.json 2023-02-13 14:30:10 +08:00
Rafi
1ee3469978 Abandoning sqlite cache 2023-02-13 14:29:01 +08:00
81 changed files with 5974 additions and 2310 deletions

View File

@@ -1,4 +1,6 @@
node_modules
database.sqlite
dist
.idea
.output
.nuxt
.env

13
.github/FUNDING.yml vendored Normal file
View File

@@ -0,0 +1,13 @@
# These are supported funding model platforms
github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
patreon: # Replace with a single Patreon username
open_collective: # Replace with a single Open Collective username
ko_fi: # Replace with a single Ko-fi username
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
liberapay: # Replace with a single Liberapay username
issuehunt: # Replace with a single IssueHunt username
otechie: # Replace with a single Otechie username
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
custom: https://www.buymeacoffee.com/WongSaang

View File

@@ -0,0 +1,36 @@
name: Docker Image CI - static
on:
release:
types: [published]
jobs:
build:
runs-on: ubuntu-latest
steps:
-
name: Checkout
uses: actions/checkout@v3
-
name: Set up QEMU
uses: docker/setup-qemu-action@v2
-
name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
-
name: Login to Docker Hub
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
-
name: Build and push
uses: docker/build-push-action@v4
with:
context: .
file: static.Dockerfile
push: true
tags: wongsaang/chatgpt-ui-client:latest-static,wongsaang/chatgpt-ui-client:${{ github.ref_name }}-static

View File

@@ -32,4 +32,4 @@ jobs:
with:
context: .
push: true
tags: wongsaang/chatgpt-ui:latest,wongsaang/chatgpt-ui:${{ github.ref_name }}
tags: wongsaang/chatgpt-ui-client:latest,wongsaang/chatgpt-ui-client:${{ github.ref_name }}

38
.github/workflows/docs.yml vendored Normal file
View File

@@ -0,0 +1,38 @@
name: docs
on:
push:
branches: [main, docs]
paths:
- 'docs/**'
- '.github/workflows/docs.yml'
workflow_dispatch:
jobs:
docs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
with:
fetch-depth: 0
- name: Setup yarn
uses: actions/setup-node@v3
with:
node-version: 18
cache: yarn
- name: Install dependencies
run: yarn install
- name: Build VuePress site
run: yarn docs:build
- name: Deploy to GitHub Pages
uses: crazy-max/ghaction-github-pages@v2
with:
target_branch: gh-pages
build_dir: docs/.vuepress/dist
env:
GITHUB_TOKEN: ${{ secrets.ACTION_TOKEN }}

3
.gitignore vendored
View File

@@ -7,4 +7,5 @@ node_modules
.env
.idea
dist
database.sqlite
.temp
.cache

View File

@@ -4,7 +4,7 @@ WORKDIR /app
COPY package.json yarn.lock ./
RUN yarn install
RUN yarn install && yarn cache clean
COPY . .
@@ -13,13 +13,14 @@ RUN yarn build
FROM node:18-alpine3.16
ENV NITRO_HOST=0.0.0.0
ENV NITRO_PORT=80
WORKDIR /app
COPY --from=builder /app/.output .
COPY --from=builder /app/.output/ .
EXPOSE 80
# TODO: You can use NITRO_PRESET=node_cluster in order to leverage multi-process performance using Node.js cluster module. https://nuxt.com/docs/getting-started/deployment
ENTRYPOINT ["node", "server/index.mjs"]

View File

@@ -1,41 +1,15 @@
<p align="center">
<img alt="demo" src="./demos/demo.gif?v=1">
</p>
<div align="center">
<h1>ChatGPT UI</h1>
</div>
# ChatGPT UI
A ChatGPT web client that supports multiple users, multiple languages, and multiple database connections for persistent data storage.
A web client for ChatGPT, using OpenAI's API. The implementation of the interface part uses [waylaidwanderer/node-chatgpt-api](https://github.com/waylaidwanderer/node-chatgpt-api)
The server of this project[https://github.com/WongSaang/chatgpt-ui-server](https://github.com/WongSaang/chatgpt-ui-server)
This project is based on [nuxt3](https://nuxt.com/docs/getting-started/introduction)
## Documentation
- [English](https://wongsaang.github.io/chatgpt-ui/)
- [中文](https://wongsaang.github.io/chatgpt-ui/zh/)
## Quick start with docker
```bash
docker run -p 80:80 wongsaang/chatgpt-ui:latest
```
## Development
https://user-images.githubusercontent.com/46235412/227156264-ca17ab17-999b-414f-ab06-3f75b5235bfe.mp4
### Setup
Make sure to install the dependencies:
```bash
# yarn
yarn install
```
### Development Server
Start the development server on http://localhost:3000
```bash
yarn dev
```
### Production
Build the application for production:
```bash
yarn build
```

72
app.vue
View File

@@ -1,67 +1,15 @@
<script setup>
const runtimeConfig = useRuntimeConfig()
const colorMode = useColorMode()
const drawer = ref(null)
const themes = ref([
{ title: 'Light', value: 'light' },
{ title: 'Dark', value: 'dark' },
{ title: 'System', value: 'system'}
])
const setTheme = (theme) => {
colorMode.preference = theme
}
onNuxtReady(() => {
fetchSystemSettings()
// api key
const apiKey = useApiKey()
apiKey.value = getStoredApiKey()
})
</script>
<template>
<v-app
:theme="$colorMode.value"
>
<v-navigation-drawer
v-model="drawer"
>
<v-list>
<ModelDialog/>
</v-list>
<template v-slot:append>
<v-divider></v-divider>
<v-list>
<ApiKeyDialog/>
<v-menu
>
<template v-slot:activator="{ props }">
<v-list-item
v-bind="props"
rounded="xl"
:prepend-icon="$colorMode.value === 'light' ? 'light_mode' : 'dark_mode'"
title="Theme mode"
></v-list-item>
</template>
<v-list>
<v-list-item
v-for="(theme, idx) in themes"
:key="idx"
@click="setTheme(theme.value)"
>
<v-list-item-title>{{ theme.title }}</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
</v-list>
</template>
</v-navigation-drawer>
<v-app-bar
class="d-lg-none"
>
<v-app-bar-nav-icon @click="drawer = !drawer"></v-app-bar-nav-icon>
<v-toolbar-title>{{ runtimeConfig.public.appName }}</v-toolbar-title>
</v-app-bar>
<v-main>
<NuxtPage/>
</v-main>
</v-app>
<NuxtLayout>
<NuxtLoadingIndicator />
<NuxtPage />
</NuxtLayout>
</template>

View File

@@ -10,17 +10,17 @@
prepend-icon="vpn_key"
color="primary"
>
Set OpenAI Api Key
{{ $t('setApiKey') }}
</v-list-item>
</template>
<v-card>
<v-card-title>
<span class="text-h5">OpenAI Api Key</span>
<span class="text-h5">{{ $t('openAIApiKey') }}</span>
</v-card-title>
<v-divider></v-divider>
<v-card-text>
<div>
Get a key:
{{ $t('getAKey') }}:
<a target="_blank" href="https://platform.openai.com/account/api-keys">https://platform.openai.com/account/api-keys</a>
</div>
<div

328
components/Conversation.vue Normal file
View File

@@ -0,0 +1,328 @@
<script setup>
import {EventStreamContentType, fetchEventSource} from '@microsoft/fetch-event-source'
const { $i18n } = useNuxtApp()
const runtimeConfig = useRuntimeConfig()
const currentModel = useCurrentModel()
const openaiApiKey = useApiKey()
const fetchingResponse = ref(false)
const messageQueue = []
const frugalMode = ref(true)
let isProcessingQueue = false
const props = defineProps({
conversation: {
type: Object,
required: true
}
})
const processMessageQueue = () => {
if (isProcessingQueue || messageQueue.length === 0) {
return
}
if (!props.conversation.messages[props.conversation.messages.length - 1].is_bot) {
props.conversation.messages.push({id: null, is_bot: true, message: ''})
}
isProcessingQueue = true
const nextMessage = messageQueue.shift()
if (runtimeConfig.public.typewriter) {
let wordIndex = 0;
const intervalId = setInterval(() => {
props.conversation.messages[props.conversation.messages.length - 1].message += nextMessage[wordIndex]
wordIndex++
if (wordIndex === nextMessage.length) {
clearInterval(intervalId)
isProcessingQueue = false
processMessageQueue()
}
}, runtimeConfig.public.typewriterDelay)
} else {
props.conversation.messages[props.conversation.messages.length - 1].message += nextMessage
isProcessingQueue = false
processMessageQueue()
}
}
let ctrl
const abortFetch = () => {
if (ctrl) {
ctrl.abort()
}
fetchingResponse.value = false
}
const fetchReply = async (message) => {
ctrl = new AbortController()
let webSearchParams = {}
if (enableWebSearch.value) {
webSearchParams['web_search'] = {
ua: navigator.userAgent,
default_prompt: $i18n.t('webSearchDefaultPrompt')
}
}
const data = Object.assign({}, currentModel.value, {
openaiApiKey: enableCustomApiKey.value ? openaiApiKey.value : null,
message: message,
conversationId: props.conversation.id,
frugalMode: frugalMode.value
}, webSearchParams)
try {
await fetchEventSource('/api/conversation/', {
signal: ctrl.signal,
method: 'POST',
headers: {
'accept': 'application/json',
'Content-Type': 'application/json',
},
body: JSON.stringify(data),
openWhenHidden: true,
onopen(response) {
if (response.ok && response.headers.get('content-type') === EventStreamContentType) {
return;
}
throw new Error(`Failed to send message. HTTP ${response.status} - ${response.statusText}`);
},
onclose() {
if (ctrl.signal.aborted === true) {
return;
}
throw new Error(`Failed to send message. Server closed the connection unexpectedly.`);
},
onerror(err) {
throw err;
},
async onmessage(message) {
const event = message.event
const data = JSON.parse(message.data)
if (event === 'error') {
abortFetch()
showSnackbar(data.error)
return;
}
if (event === 'userMessageId') {
props.conversation.messages[props.conversation.messages.length - 1].id = data.userMessageId
return;
}
if (event === 'done') {
abortFetch()
props.conversation.messages[props.conversation.messages.length - 1].id = data.messageId
if (!props.conversation.id) {
props.conversation.id = data.conversationId
genTitle(props.conversation.id)
}
return;
}
messageQueue.push(data.content)
processMessageQueue()
scrollChatWindow()
},
})
} catch (err) {
console.log(err)
abortFetch()
showSnackbar(err.message)
}
}
const grab = ref(null)
const scrollChatWindow = () => {
if (grab.value === null) {
return;
}
grab.value.scrollIntoView({behavior: 'smooth'})
}
const send = (message) => {
fetchingResponse.value = true
if (props.conversation.messages.length === 0) {
addConversation(props.conversation)
}
props.conversation.messages.push({message: message})
fetchReply(message)
scrollChatWindow()
}
const stop = () => {
abortFetch()
}
const snackbar = ref(false)
const snackbarText = ref('')
const showSnackbar = (text) => {
snackbarText.value = text
snackbar.value = true
}
const editor = ref(null)
const usePrompt = (prompt) => {
editor.value.usePrompt(prompt)
}
const deleteMessage = (index) => {
props.conversation.messages.splice(index, 1)
}
const settings = useSettings()
const enableWebSearch = ref(false)
const showWebSearchToggle = computed(() => {
return settings.value && settings.value.open_web_search && settings.value.open_web_search === 'True'
})
const enableCustomApiKey = computed(() => {
return settings.value && settings.value.open_api_key_setting && settings.value.open_api_key_setting === 'True'
})
onNuxtReady(() => {
currentModel.value = getCurrentModel()
})
</script>
<template>
<div v-if="conversation">
<div
v-if="conversation.loadingMessages"
class="text-center"
>
<v-progress-circular
indeterminate
color="primary"
></v-progress-circular>
</div>
<div v-else>
<div
v-if="conversation.messages"
ref="chatWindow"
>
<v-container>
<v-row>
<v-col
v-for="(message, index) in conversation.messages" :key="index"
cols="12"
>
<div
class="d-flex align-center"
:class="message.is_bot ? 'justify-start' : 'justify-end'"
>
<MessageActions
v-if="!message.is_bot"
:message="message"
:message-index="index"
:use-prompt="usePrompt"
:delete-message="deleteMessage"
/>
<MsgContent :message="message" />
<MessageActions
v-if="message.is_bot"
:message="message"
:message-index="index"
:use-prompt="usePrompt"
:delete-message="deleteMessage"
/>
</div>
</v-col>
</v-row>
</v-container>
<div ref="grab" class="w-100" style="height: 200px;"></div>
</div>
</div>
</div>
<v-footer
app
class="footer"
>
<div class="px-md-16 w-100 d-flex flex-column">
<div class="d-flex align-center">
<v-btn
v-show="fetchingResponse"
icon="close"
title="stop"
class="mr-3"
@click="stop"
></v-btn>
<MsgEditor ref="editor" :send-message="send" :disabled="fetchingResponse" :loading="fetchingResponse" />
</div>
<v-toolbar
density="comfortable"
color="transparent"
>
<Prompt v-show="!fetchingResponse" :use-prompt="usePrompt" />
<v-switch
v-if="showWebSearchToggle"
v-model="enableWebSearch"
inline
hide-details
color="primary"
:label="$t('webSearch')"
></v-switch>
<v-spacer></v-spacer>
<v-switch
v-model="frugalMode"
inline
hide-details
color="primary"
:label="$t('frugalMode')"
></v-switch>
<v-dialog
transition="dialog-bottom-transition"
width="auto"
>
<template v-slot:activator="{ props }">
<v-icon
color="grey"
v-bind="props"
icon="help_outline"
></v-icon>
</template>
<template v-slot:default="{ isActive }">
<v-card>
<v-toolbar
color="primary"
:title="$t('frugalMode')"
></v-toolbar>
<v-card-text>
{{ $t('frugalModeTip') }}
</v-card-text>
</v-card>
</template>
</v-dialog>
</v-toolbar>
</div>
</v-footer>
<v-snackbar
v-model="snackbar"
multi-line
location="top"
>
{{ snackbarText }}
<template v-slot:actions>
<v-btn
color="red"
variant="text"
@click="snackbar = false"
>
Close
</v-btn>
</template>
</v-snackbar>
</template>
<style scoped>
.footer {
width: 100%;
}
</style>

View File

@@ -0,0 +1,98 @@
<script setup>
import copy from 'copy-to-clipboard'
const props = defineProps({
message: {
type: Object,
required: true
},
messageIndex: {
type: Number,
required: true
},
usePrompt: {
type: Function,
required: true
},
deleteMessage: {
type: Function,
required: true
}
})
const snackbar = ref(false)
const snackbarText = ref('')
const showSnackbar = (text) => {
snackbarText.value = text
snackbar.value = true
}
const copyMessage = () => {
copy(props.message.message)
showSnackbar('Copied!')
}
const editMessage = () => {
props.usePrompt(props.message.message)
}
const deleteMessage = async () => {
const { data, error } = await useAuthFetch(`/api/chat/messages/${props.message.id}/`, {
method: 'DELETE'
})
if (!error.value) {
props.deleteMessage(props.messageIndex)
showSnackbar('Deleted!')
}
showSnackbar('Delete failed')
}
</script>
<template>
<v-menu
>
<template v-slot:activator="{ props }">
<v-btn
v-bind="props"
icon
variant="text"
class="mx-1"
>
<v-icon icon="more_horiz"></v-icon>
</v-btn>
</template>
<v-list>
<v-list-item
@click="copyMessage()"
:title="$t('copy')"
prepend-icon="content_copy"
>
</v-list-item>
<v-list-item
@click="editMessage()"
:title="$t('edit')"
prepend-icon="edit"
>
</v-list-item>
<v-list-item
@click="deleteMessage()"
:title="$t('delete')"
prepend-icon="delete"
>
</v-list-item>
</v-list>
</v-menu>
<v-snackbar
v-model="snackbar"
location="top"
timeout="2000"
>
{{ snackbarText }}
</v-snackbar>
</template>
<style scoped>
</style>

View File

@@ -17,12 +17,12 @@
</template>
<v-card>
<v-card-title>
<span class="text-h5">OpenAI Models</span>
<span class="text-h5">{{ $t('openAIModels') }}</span>
</v-card-title>
<v-divider></v-divider>
<v-card-text>
<div>
About the models:
{{ $t('aboutTheModels') }}:
<a target="_blank" href="https://platform.openai.com/docs/models/overview">https://platform.openai.com/docs/models/overview</a>
</div>
<div
@@ -77,7 +77,7 @@
color="primary"
@click="save"
>
Save & Close
{{ $t('saveAndClose') }}
</v-btn>
</v-card-actions>
</v-card>
@@ -85,6 +85,7 @@
</template>
<script setup>
const { $i18n } = useNuxtApp()
const dialog = ref(false)
const models = useModels()
const currentModel = useCurrentModel()
@@ -110,7 +111,7 @@ const removeModel = (index) => {
}
const save = async () => {
if (!currentModel.value) {
showWarning('Please select at least one model.')
showWarning($i18n.t('pleaseSelectAtLeastOneModelDot'))
return
}
setModels(models.value)

View File

@@ -0,0 +1,201 @@
<script setup>
const dialog = ref(false)
const currentModel = useCurrentModel()
const availableModels = [
'gpt-3.5-turbo',
'gpt-4'
]
const currentModelDefault = ref(MODELS[currentModel.value.name])
onNuxtReady(() => {
currentModel.value = getCurrentModel()
watch(currentModel, (newVal, oldVal) => {
currentModelDefault.value = MODELS[newVal.name]
saveCurrentModel(newVal)
}, { deep: true })
})
</script>
<template>
<v-dialog
v-model="dialog"
persistent
>
<template v-slot:activator="{ props }">
<v-list-item
v-bind="props"
rounded="xl"
prepend-icon="tune"
:title="$t('modelParameters')"
></v-list-item>
</template>
<v-card>
<v-toolbar
density="compact"
>
<v-toolbar-title>{{ $t('modelParameters') }}</v-toolbar-title>
<v-spacer></v-spacer>
<v-btn icon="close" @click="dialog = false"></v-btn>
</v-toolbar>
<v-card-text>
<v-select
v-model="currentModel.name"
:label="$t('model')"
:items="availableModels"
variant="underlined"
></v-select>
<v-row
no-gutters
>
<v-col cols="12">
<div class="d-flex justify-space-between align-center">
<v-list-subheader>{{ $t('temperature') }}</v-list-subheader>
<v-text-field
v-model.number="currentModel.temperature"
hide-details
single-line
density="compact"
type="number"
max="1"
step="0.01"
style="width: 100px"
class="flex-grow-0"
></v-text-field>
</div>
</v-col>
<v-col cols="12">
<v-slider
v-model="currentModel.temperature"
:max="1"
:step="0.01"
hide-details
>
</v-slider>
</v-col>
</v-row>
<v-row
no-gutters
>
<v-col cols="12">
<div class="d-flex justify-space-between align-center">
<v-list-subheader>{{ $t('maxTokens') }}</v-list-subheader>
<v-text-field
v-model.number="currentModel.max_tokens"
hide-details
single-line
density="compact"
type="number"
:max="currentModelDefault.total_tokens"
step="1"
style="width: 100px"
class="flex-grow-0"
></v-text-field>
</div>
<div class="text-caption">
{{ $t('maxTokenTips1') }} <b>{{ currentModelDefault.total_tokens }}</b> {{ $t('maxTokenTips2') }}
</div>
</v-col>
<v-col cols="12">
<v-slider
v-model="currentModel.max_tokens"
:max="currentModelDefault.total_tokens"
:step="1"
hide-details
>
</v-slider>
</v-col>
</v-row>
<v-row
no-gutters
>
<v-col cols="12">
<div class="d-flex justify-space-between align-center">
<v-list-subheader>{{ $t('topP') }}</v-list-subheader>
<v-text-field
v-model.number="currentModel.top_p"
hide-details
single-line
density="compact"
type="number"
max="1"
step="0.01"
style="width: 100px"
class="flex-grow-0"
></v-text-field>
</div>
</v-col>
<v-col cols="12">
<v-slider
v-model="currentModel.top_p"
:max="1"
:step="0.01"
hide-details
>
</v-slider>
</v-col>
</v-row>
<v-row no-gutters>
<v-col cols="12">
<div class="d-flex justify-space-between align-center">
<v-list-subheader>{{ $t('frequencyPenalty') }}</v-list-subheader>
<v-text-field
v-model.number="currentModel.frequency_penalty"
hide-details
single-line
density="compact"
type="number"
max="2"
step="0.01"
style="width: 100px"
class="flex-grow-0"
></v-text-field>
</div>
</v-col>
<v-col cols="12">
<v-slider
v-model="currentModel.frequency_penalty"
:max="2"
:step="0.01"
hide-details
></v-slider>
</v-col>
</v-row>
<v-row no-gutters>
<v-col cols="12">
<div class="d-flex justify-space-between align-center">
<v-list-subheader>{{ $t('presencePenalty') }}</v-list-subheader>
<v-text-field
v-model.number="currentModel.presence_penalty"
hide-details
single-line
density="compact"
type="number"
max="2"
step="0.01"
style="width: 100px"
class="flex-grow-0"
></v-text-field>
</div>
</v-col>
<v-col cols="12">
<v-slider
v-model="currentModel.presence_penalty"
:max="2"
:step="0.01"
hide-details
></v-slider>
</v-col>
</v-row>
</v-card-text>
</v-card>
</v-dialog>
</template>
<style scoped>
</style>

View File

@@ -1,25 +1,92 @@
<script setup>
import { marked } from "marked"
import hljs from "highlight.js"
import MarkdownIt from 'markdown-it'
import copy from 'copy-to-clipboard'
marked.setOptions({
highlight: function (code, lang) {
const md = new MarkdownIt({
linkify: true,
highlight(code, lang) {
const language = hljs.getLanguage(lang) ? lang : 'plaintext'
return hljs.highlight(code, { language }).value
return `<pre class="hljs-code-container my-3"><div class="hljs-code-header d-flex align-center justify-space-between bg-grey-darken-3 pa-1"><span class="pl-2 text-caption">${language}</span><button class="hljs-copy-button" data-copied="false">Copy</button></div><code class="hljs language-${language}">${hljs.highlight(code, { language: language, ignoreIllegals: true }).value}</code></pre>`
},
langPrefix: 'hljs language-', // highlight.js css class prefix
})
const props = defineProps(['content'])
const contentHtml = computed(() => {
return props.content ? marked(props.content) : ''
const props = defineProps({
message: {
type: Object,
required: true
}
})
const contentHtml = ref('')
const contentElm = ref(null)
watchEffect(async () => {
contentHtml.value = props.message.message ? md.render(props.message.message) : ''
await nextTick()
bindCopyCodeToButtons()
})
const bindCopyCodeToButtons = () => {
if (!contentElm.value) {
return
}
contentElm.value.querySelectorAll('.hljs-code-container').forEach((codeContainer) => {
const copyButton = codeContainer.querySelector('.hljs-copy-button');
const codeBody = codeContainer.querySelector('code');
copyButton.onclick = function () {
copy(codeBody.textContent ?? '');
copyButton.innerHTML = "Copied!";
copyButton.dataset.copied = 'true';
setTimeout(() => {
copyButton.innerHTML = "Copy";
copyButton.dataset.copied = 'false';
}, 2000);
};
})
}
onMounted(() => {
bindCopyCodeToButtons()
})
</script>
<template>
<div
v-html="contentHtml"
></div>
<v-card
:color="message.is_bot ? '' : 'primary'"
rounded="lg"
elevation="2"
>
<div
ref="contentElm"
v-html="contentHtml"
class="chat-msg-content pa-3"
></div>
</v-card>
</template>
</template>
<style>
.chat-msg-content ol, .chat-msg-content ul {
padding-left: 2em;
}
.hljs-code-container {
border-radius: 3px;
overflow: hidden;
}
.hljs-copy-button{
width:2rem;height:2rem;text-indent:-9999px;color:#fff;
border-radius:.25rem;border:1px solid #ffffff22;
background-image:url('data:image/svg+xml;utf-8,<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" clip-rule="evenodd" d="M6 5C5.73478 5 5.48043 5.10536 5.29289 5.29289C5.10536 5.48043 5 5.73478 5 6V20C5 20.2652 5.10536 20.5196 5.29289 20.7071C5.48043 20.8946 5.73478 21 6 21H18C18.2652 21 18.5196 20.8946 18.7071 20.7071C18.8946 20.5196 19 20.2652 19 20V6C19 5.73478 18.8946 5.48043 18.7071 5.29289C18.5196 5.10536 18.2652 5 18 5H16C15.4477 5 15 4.55228 15 4C15 3.44772 15.4477 3 16 3H18C18.7956 3 19.5587 3.31607 20.1213 3.87868C20.6839 4.44129 21 5.20435 21 6V20C21 20.7957 20.6839 21.5587 20.1213 22.1213C19.5587 22.6839 18.7957 23 18 23H6C5.20435 23 4.44129 22.6839 3.87868 22.1213C3.31607 21.5587 3 20.7957 3 20V6C3 5.20435 3.31607 4.44129 3.87868 3.87868C4.44129 3.31607 5.20435 3 6 3H8C8.55228 3 9 3.44772 9 4C9 4.55228 8.55228 5 8 5H6Z" fill="white"/><path fill-rule="evenodd" clip-rule="evenodd" d="M7 3C7 1.89543 7.89543 1 9 1H15C16.1046 1 17 1.89543 17 3V5C17 6.10457 16.1046 7 15 7H9C7.89543 7 7 6.10457 7 5V3ZM15 3H9V5H15V3Z" fill="white"/></svg>');
background-repeat:no-repeat;background-position:center;
transition:background-color 200ms ease,transform 200ms ease-out
}
.hljs-copy-button:hover{border-color:#ffffff44}
.hljs-copy-button:active{border-color:#ffffff66}
.hljs-copy-button[data-copied="true"]{text-indent:0;width:auto;background-image:none}
@media(prefers-reduced-motion){.hljs-copy-button{transition:none}}
</style>

View File

@@ -1,75 +1,97 @@
<template>
<v-textarea
v-model="message"
label="Write a message..."
placeholder="Write a message..."
rows="1"
:auto-grow="autoGrow"
:disabled="disabled"
:loading="loading"
:hint="hint"
append-inner-icon="send"
@keyup.enter.exact="enterOnly"
@click:appendInner="clickSendBtn"
></v-textarea>
</template>
<script>
<script setup>
import { isMobile } from 'is-mobile'
export default {
name: "MsgEditor",
props: {
sendMessage: Function,
disabled: Boolean,
loading: Boolean,
const { $i18n } = useNuxtApp()
const props = defineProps({
sendMessage: {
type: Function,
required: true
},
data() {
return {
message: "",
rows: 1,
autoGrow: true,
};
},
computed: {
hint() {
return isMobile() ? "" : "Press Enter to send your message or Shift+Enter to add a new line.";
},
},
watch: {
message(val) {
const lines = val.split(/\r\n|\r|\n/).length;
if (lines > 8) {
this.rows = lines;
this.autoGrow = false;
} else {
this.rows = 1;
this.autoGrow = true;
}
},
},
methods: {
send() {
let msg = this.message
// remove the last "\n"
if (msg[msg.length - 1] === "\n") {
msg = msg.slice(0, -1)
}
if (msg.length > 0) {
this.sendMessage(msg)
}
this.message = ""
},
clickSendBtn () {
this.send()
},
enterOnly () {
if (!isMobile()) {
this.send()
}
}
disabled: {
type: Boolean,
default: false
},
loading: {
type: Boolean,
default: false
}
})
const message = ref('')
const rows = ref(1)
const autoGrow = ref(true)
const hint = computed(() => {
return isMobile() ? '' : $i18n.t('pressEnterToSendYourMessageOrShiftEnterToAddANewLine')
})
watchEffect(() => {
const lines = message.value.split(/\r\n|\r|\n/).length
if (lines > 8) {
rows.value = 8
autoGrow.value = false
} else {
rows.value = 1
autoGrow.value = true
}
})
const send = () => {
let msg = message.value
// remove the last "\n"
if (msg[msg.length - 1] === "\n") {
msg = msg.slice(0, -1)
}
if (msg.length > 0) {
props.sendMessage(msg)
}
message.value = ""
}
const usePrompt = (prompt) => {
message.value = prompt
}
const clickSendBtn = () => {
send()
}
const enterOnly = (event) => {
event.preventDefault();
if (!isMobile()) {
send()
}
}
defineExpose({
usePrompt
})
</script>
<style scoped>
</style>
<template>
<div
class="flex-grow-1 d-flex align-center justify-space-between"
>
<v-textarea
v-model="message"
:label="$t('writeAMessage')"
:placeholder="hint"
:rows="rows"
max-rows="8"
:auto-grow="autoGrow"
:disabled="disabled"
:loading="loading"
:hide-details="true"
clearable
variant="outlined"
@keydown.enter.exact="enterOnly"
></v-textarea>
<v-btn
:disabled="loading"
icon="send"
title="Send"
class="ml-3"
@click="clickSendBtn"
></v-btn>
</div>
</template>

View File

@@ -0,0 +1,346 @@
<script setup>
import { useDisplay } from 'vuetify'
import {useDrawer} from "../composables/states";
const route = useRoute()
const { $i18n } = useNuxtApp()
const colorMode = useColorMode()
const {mdAndUp} = useDisplay()
const drawerPermanent = computed(() => {
return mdAndUp.value
})
const user = useUser()
const themes = ref([
{ title: $i18n.t('lightMode'), value: 'light' },
{ title: $i18n.t('darkMode'), value: 'dark' },
{ title: $i18n.t('followSystem'), value: 'system'}
])
const setTheme = (theme) => {
colorMode.preference = theme
}
const feedback = () => {
window.open('https://github.com/WongSaang/chatgpt-ui/issues', '_blank')
}
const { locale, locales, setLocale } = useI18n()
const setLang = (lang) => {
setLocale(lang)
}
const conversations = useConversations()
const editingConversation = ref(null)
const deletingConversationIndex = ref(null)
const editConversation = (index) => {
editingConversation.value = conversations.value[index]
}
const updateConversation = async (index) => {
editingConversation.value.updating = true
const { data, error } = await useAuthFetch(`/api/chat/conversations/${editingConversation.value.id}/`, {
method: 'PUT',
body: JSON.stringify({
topic: editingConversation.value.topic
})
})
if (!error.value) {
conversations.value[index] = editingConversation.value
}
editingConversation.value = null
}
const deleteConversation = async (index) => {
deletingConversationIndex.value = index
const { data, error } = await useAuthFetch(`/api/chat/conversations/${conversations.value[index].id}/`, {
method: 'DELETE'
})
deletingConversationIndex.value = null
if (!error.value) {
const deletingConversation = conversations.value[index]
conversations.value.splice(index, 1)
if (route.params.id && parseInt(route.params.id) === deletingConversation.id) {
await navigateTo('/')
}
}
}
const clearConversations = async () => {
deletingConversations.value = true
const { data, error } = await useAuthFetch(`/api/chat/conversations/delete_all`, {
method: 'DELETE'
})
if (!error.value) {
loadConversations()
clearConfirmDialog.value = false
}
deletingConversations.value = false
}
const clearConfirmDialog = ref(false)
const deletingConversations = ref(false)
const loadingConversations = ref(false)
const loadConversations = async () => {
loadingConversations.value = true
conversations.value = await getConversations()
loadingConversations.value = false
}
const settings = useSettings()
const showApiKeySetting = computed(() => {
return settings.value && settings.value.open_api_key_setting && settings.value.open_api_key_setting === 'True'
})
const signOut = async () => {
const { data, error } = await useFetch('/api/account/logout/', {
method: 'POST'
})
if (!error.value) {
await logout()
}
}
onNuxtReady(async () => {
loadConversations()
})
const drawer = useDrawer()
</script>
<template>
<v-navigation-drawer
v-model="drawer"
:permanent="drawerPermanent"
width="300"
>
<template
v-slot:prepend
v-if="user"
>
<v-list>
<v-list-item
:title="user.username"
:subtitle="user.email"
>
<template v-slot:prepend>
<v-icon
icon="face"
size="x-large"
></v-icon>
</template>
<template v-slot:append>
<v-menu>
<template v-slot:activator="{ props }">
<v-btn
v-bind="props"
size="small"
variant="text"
icon="expand_more"
></v-btn>
</template>
<v-list>
<v-list-item
:title="$t('resetPassword')"
to="/account/resetPassword"
>
</v-list-item>
<v-list-item
:title="$t('signOut')"
@click="signOut"
>
</v-list-item>
</v-list>
</v-menu>
</template>
</v-list-item>
</v-list>
<v-divider></v-divider>
</template>
<div class="px-2">
<v-list>
<v-list-item v-show="loadingConversations">
<v-list-item-title class="d-flex justify-center">
<v-progress-circular indeterminate></v-progress-circular>
</v-list-item-title>
</v-list-item>
</v-list>
<v-list>
<template
v-for="(conversation, cIdx) in conversations"
:key="conversation.id"
>
<v-list-item
active-color="primary"
rounded="xl"
v-if="editingConversation && editingConversation.id === conversation.id"
>
<v-text-field
v-model="editingConversation.topic"
:loading="editingConversation.updating"
variant="underlined"
append-icon="done"
hide-details
density="compact"
autofocus
@keyup.enter="updateConversation(cIdx)"
@click:append="updateConversation(cIdx)"
></v-text-field>
</v-list-item>
<v-hover
v-if="!editingConversation || editingConversation.id !== conversation.id"
v-slot="{ isHovering, props }"
>
<v-list-item
rounded="xl"
active-color="primary"
:to="conversation.id ? `/${conversation.id}` : '/'"
v-bind="props"
>
<v-list-item-title>{{ (conversation.topic && conversation.topic !== '') ? conversation.topic : $t('defaultConversationTitle') }}</v-list-item-title>
<template v-slot:append>
<div
v-show="isHovering && conversation.id"
>
<v-btn
icon="edit"
size="small"
variant="text"
@click.prevent="editConversation(cIdx)"
>
</v-btn>
<v-btn
icon="delete"
size="small"
variant="text"
:loading="deletingConversationIndex === cIdx"
@click.prevent="deleteConversation(cIdx)"
>
</v-btn>
</div>
</template>
</v-list-item>
</v-hover>
</template>
</v-list>
</div>
<template v-slot:append>
<div class="px-1">
<v-divider></v-divider>
<v-list>
<v-dialog
v-model="clearConfirmDialog"
persistent
>
<template v-slot:activator="{ props }">
<v-list-item
v-bind="props"
rounded="xl"
prepend-icon="delete_forever"
:title="$t('clearConversations')"
></v-list-item>
</template>
<v-card>
<v-card-title class="text-h5">
Are you sure you want to delete all conversations?
</v-card-title>
<v-card-text>This will be a permanent deletion and cannot be retrieved once deleted. Please proceed with caution.</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn
color="green-darken-1"
variant="text"
@click="clearConfirmDialog = false"
class="text-none"
>
Cancel deletion
</v-btn>
<v-btn
color="green-darken-1"
variant="text"
@click="clearConversations"
class="text-none"
:loading="deletingConversations"
>
Confirm deletion
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<ApiKeyDialog
v-if="showApiKeySetting"
/>
<ModelParameters/>
<v-menu
>
<template v-slot:activator="{ props }">
<v-list-item
v-bind="props"
rounded="xl"
:title="$t('themeMode')"
>
<template
v-slot:prepend
>
<v-icon
v-show="$colorMode.value === 'light'"
icon="light_mode"
></v-icon>
<v-icon
v-show="$colorMode.value !== 'light'"
icon="dark_mode"
></v-icon>
</template>
</v-list-item>
</template>
<v-list
bg-color="white"
>
<v-list-item
v-for="(theme, idx) in themes"
:key="idx"
@click="setTheme(theme.value)"
>
<v-list-item-title>{{ theme.title }}</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
<SettingsLanguages/>
<v-list-item
rounded="xl"
prepend-icon="help_outline"
:title="$t('feedback')"
@click="feedback"
></v-list-item>
</v-list>
</div>
</template>
</v-navigation-drawer>
</template>
<style>
.v-navigation-drawer__content::-webkit-scrollbar {
width: 0;
}
.v-navigation-drawer__content:hover::-webkit-scrollbar {
width: 6px;
}
.v-navigation-drawer__content:hover::-webkit-scrollbar-thumb {
background-color: #999;
border-radius: 3px;
}
</style>

261
components/Prompt.vue Normal file
View File

@@ -0,0 +1,261 @@
<script setup>
const menu = ref(false)
const prompts = ref([])
const editingPrompt = ref(null)
const newTitlePrompt = ref(null)
const newPrompt = ref('')
const submittingNewPrompt = ref(false)
const promptInputErrorMessage = ref('')
const loadingPrompts = ref(false)
const deletingPromptIndex = ref(null)
const props = defineProps({
usePrompt: {
type: Function,
required: true
}
})
const addPrompt = async () => {
if (!newPrompt.value) {
promptInputErrorMessage.value = 'Please enter a prompt'
return
}
submittingNewPrompt.value = true
const { data, error } = await useAuthFetch('/api/chat/prompts/', {
method: 'POST',
body: JSON.stringify({
title: newTitlePrompt.value,
prompt: newPrompt.value
})
})
if (!error.value) {
prompts.value.push(data.value)
newTitlePrompt.value = null
newPrompt.value = ''
}
submittingNewPrompt.value = false
}
const editPrompt = (index) => {
editingPrompt.value = Object.assign({}, prompts.value[index])
}
const updatePrompt = async (index) => {
editingPrompt.value.updating = true
const { data, error } = await useAuthFetch(`/api/chat/prompts/${editingPrompt.value.id}/`, {
method: 'PUT',
body: JSON.stringify({
title: editingPrompt.value.title,
prompt: editingPrompt.value.prompt
})
})
if (!error.value) {
prompts.value[index] = editingPrompt.value
}
editingPrompt.value.updating = false
editingPrompt.value = null
}
const cancelEditPrompt = () => {
editingPrompt.value = null
}
const deletePrompt = async (index) => {
deletingPromptIndex.value = index
const { data, error } = await useAuthFetch(`/api/chat/prompts/${prompts.value[index].id}/`, {
method: 'DELETE'
})
deletingPromptIndex.value = null
if (!error.value) {
prompts.value.splice(index, 1)
}
}
const loadPrompts = async () => {
loadingPrompts.value = true
const { data, error } = await useAuthFetch('/api/chat/prompts/')
if (!error.value) {
prompts.value = data.value
}
loadingPrompts.value = false
}
const selectPrompt = (prompt) => {
props.usePrompt(prompt.prompt)
menu.value = false
}
onNuxtReady( () => {
loadPrompts()
})
</script>
<template>
<div>
<v-menu
v-model="menu"
:close-on-content-click="false"
>
<template v-slot:activator="{ props }">
<v-btn
v-bind="props"
icon
>
<v-icon
icon="speaker_notes"
></v-icon>
</v-btn>
</template>
<v-container>
<v-card
min-width="300"
max-width="500"
>
<v-card-title>
<span class="headline">{{ $t('frequentlyPrompts') }}</span>
</v-card-title>
<v-divider></v-divider>
<v-list>
<v-list-item v-show="loadingPrompts">
<v-list-item-title class="d-flex justify-center">
<v-progress-circular indeterminate></v-progress-circular>
</v-list-item-title>
</v-list-item>
<template
v-for="(prompt, idx) in prompts"
:key="prompt.id"
>
<v-list-item
active-color="primary"
v-if="editingPrompt && editingPrompt.id === prompt.id"
>
<div class="d-flex flex-row" :style="{ marginTop: '5px' }">
<div class="flex-grow-1">
<v-text-field
v-model="editingPrompt.title"
:loading="editingPrompt.updating"
:label="$t('titlePrompt')"
variant="underlined"
density="compact"
hide-details
>
</v-text-field>
<v-textarea
rows="2"
v-model="editingPrompt.prompt"
:loading="editingPrompt.updating"
variant="underlined"
density="compact"
hide-details
>
</v-textarea>
</div>
<div>
<div class="d-flex flex-column">
<v-btn
icon="done"
variant="text"
:loading="editingPrompt.updating"
@click="updatePrompt(idx)"
>
</v-btn>
<v-btn
icon="close"
variant="text"
@click="cancelEditPrompt()"
>
</v-btn>
</div>
</div>
</div>
</v-list-item>
<v-list-item
v-if="!editingPrompt || editingPrompt.id !== prompt.id"
rounded="xl"
active-color="primary"
@click="selectPrompt(prompt)"
>
<v-list-item-title>{{ prompt.title ? prompt.title : prompt.prompt }}</v-list-item-title>
<template v-slot:append>
<v-btn
icon="edit"
size="small"
variant="text"
@click="editPrompt(idx)"
>
</v-btn>
<v-btn
icon="delete"
size="small"
variant="text"
:loading="deletingPromptIndex === idx"
@click="deletePrompt(idx)"
>
</v-btn>
</template>
</v-list-item>
</template>
<v-list-item
active-color="primary"
>
<div
class="pt-3"
>
<v-text-field
rows="1"
v-model="newTitlePrompt"
:label="$t('titlePrompt')"
variant="outlined"
density="compact"
hide-details
clearable
>
</v-text-field>
</div>
</v-list-item>
<v-list-item
active-color="primary"
>
<div
class="pt-3"
>
<v-textarea
rows="2"
v-model="newPrompt"
:label="$t('addNewPrompt')"
variant="outlined"
density="compact"
:error-messages="promptInputErrorMessage"
@update:modelValue="promptInputErrorMessage = ''"
clearable
>
</v-textarea>
</div>
</v-list-item>
<v-list-item>
<v-btn
variant="text"
block
:loading="submittingNewPrompt"
@click="addPrompt()"
>
<v-icon icon="add"></v-icon>
{{ $t('addPrompt') }}
</v-btn>
</v-list-item>
</v-list>
</v-card>
</v-container>
</v-menu>
</div>
</template>
<style scoped>
</style>

83
components/Welcome.vue Normal file
View File

@@ -0,0 +1,83 @@
<template>
<v-container>
<v-row>
<v-col cols="12">
<div class="text-center">
<h2 class="text-h2">{{ $t('welcomeTo') }} <span class="text-primary">{{ runtimeConfig.public.appName }}</span></h2>
<p class="text-caption my-5">
{{ runtimeConfig.public.appName }} {{ $t('welcomeScreen.introduction1') }}
<br>
</p>
</div>
</v-col>
</v-row>
<v-row>
<v-col cols="12" md="10" offset-md="1">
<v-row>
<v-col
cols="12"
md="4"
>
<v-row>
<v-col>
<div class="d-flex flex-column align-center">
<v-icon icon="sunny"></v-icon>
<h3 class="text-h6">{{ $t('welcomeScreen.examples.title') }}</h3>
</div>
</v-col>
</v-row>
<WelcomeCard v-for="example in examples" :content="example" />
</v-col>
<v-col
cols="12"
md="4"
>
<v-row>
<v-col>
<div class="d-flex flex-column align-center">
<v-icon icon="bolt"></v-icon>
<h3 class="text-h6">{{ $t('welcomeScreen.capabilities.title') }}</h3>
</div>
</v-col>
</v-row>
<WelcomeCard v-for="capabilitie in capabilities" :content="capabilitie" />
</v-col>
<v-col
cols="12"
md="4"
>
<v-row>
<v-col>
<div class="d-flex flex-column align-center">
<v-icon icon="warning_amber"></v-icon>
<h3 class="text-h6">{{ $t('welcomeScreen.limitations.title') }}</h3>
</div>
</v-col>
</v-row>
<WelcomeCard v-for="limitation in limitations" :content="limitation" />
</v-col>
</v-row>
</v-col>
</v-row>
</v-container>
</template>
<script setup>
const runtimeConfig = useRuntimeConfig()
const { $i18n } = useNuxtApp()
const examples = ref([
$i18n.t('welcomeScreen.examples.item1'),
$i18n.t('welcomeScreen.examples.item2'),
$i18n.t('welcomeScreen.examples.item3')
])
const capabilities = ref([
$i18n.t('welcomeScreen.capabilities.item1'),
$i18n.t('welcomeScreen.capabilities.item2'),
$i18n.t('welcomeScreen.capabilities.item3')
])
const limitations = ref([
$i18n.t('welcomeScreen.limitations.item1'),
$i18n.t('welcomeScreen.limitations.item2'),
$i18n.t('welcomeScreen.limitations.item3')
])
</script>

View File

@@ -0,0 +1,24 @@
<template>
<v-row>
<v-col>
<v-hover
v-slot="{ isHovering, props }"
open-delay="100"
>
<v-card
:elevation="isHovering ? 3 : 0"
v-bind="props"
variant="tonal"
>
<v-card-text class="text-center">
{{ content }}
</v-card-text>
</v-card>
</v-hover>
</v-col>
</v-row>
</template>
<script setup>
const props = defineProps(['content'])
</script>

View File

@@ -0,0 +1,83 @@
<template>
<v-dialog
v-model="dialog"
fullscreen
:scrim="false"
transition="dialog-bottom-transition"
>
<template v-slot:activator="{ props }">
<v-list-item
v-bind="props"
rounded="xl"
prepend-icon="language"
:title="$t('language')"
></v-list-item>
</template>
<v-card>
<v-toolbar
>
<v-btn
icon
@click="dialog = false"
>
<v-icon icon="close"></v-icon>
</v-btn>
<v-toolbar-title>{{ $t('language') }}</v-toolbar-title>
<v-spacer></v-spacer>
<v-toolbar-items>
<v-btn
variant="text"
@click="dialog = false"
>
Save
</v-btn>
</v-toolbar-items>
</v-toolbar>
<v-list
>
<!-- <v-list-item-->
<!-- title="Use device language"-->
<!-- :append-icon="usingDeviceLanguage() ? 'radio_button_checked' : 'radio_button_unchecked'"-->
<!-- @click="useDeviceLanguage"-->
<!-- >-->
<!-- </v-list-item>-->
<v-list-item
v-for="l in locales"
:key="l.code"
:title="l.name"
:append-icon="radioIcon(l.code)"
@click="updateLocale(l.code)"
>
</v-list-item>
</v-list>
</v-card>
</v-dialog>
</template>
<script setup>
const dialog = ref(false)
const { locale, locales, setLocale } = useI18n()
const { $i18n } = useNuxtApp()
// const usingDeviceLanguage = () => {
// return ($i18n.getLocaleCookie() === undefined || $i18n.getLocaleCookie() === 'undefined')
// }
const updateLocale = (lang) => {
setLocale(lang)
}
const radioIcon = (code) => {
return code === locale.value ? 'radio_button_checked' : 'radio_button_unchecked'
}
// const useDeviceLanguage = () => {
// setLocale($i18n.getBrowserLocale())
// $i18n.setLocaleCookie(undefined)
// }
</script>
<style scoped>
</style>

18
composables/fetch.js Normal file
View File

@@ -0,0 +1,18 @@
export const useMyFetch = (url, options = {}) => {
let defaultOptions = {
headers: {
Accept: 'application/json'
}
}
if (process.server) {
defaultOptions.baseURL = process.env.SERVER_DOMAIN
}
return useFetch(url, Object.assign(defaultOptions, options))
}
export const useAuthFetch = async (url, options = {}) => {
const res = await useMyFetch(url, options)
if (res.error.value && res.error.value.status === 401) {
await logout()
}
return res
}

View File

@@ -1,5 +1,14 @@
export const useModels = () => useState('models', () => getStoredModels())
// export const useModels = () => useState('models', () => getStoredModels())
export const useCurrentModel = () => useState('currentModel', () => getCurrentModel())
export const useApiKey = () => useState('apiKey', () => getStoredApiKey())
export const useApiKey = () => useState('apiKey', () => getStoredApiKey())
export const useConversations = () => useState('conversations', () => [])
export const useSettings = () => useState('settings', () => {})
export const useUser = () => useState('user', () => null)
export const useDrawer = () => useState('drawer', () => false)

BIN
demos/bmc_qr.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 917 KiB

After

Width:  |  Height:  |  Size: 144 KiB

BIN
demos/demo.mp4 Normal file

Binary file not shown.

BIN
demos/demo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

81
deployment.sh Normal file
View File

@@ -0,0 +1,81 @@
#!/bin/bash
read -p "Please enter a domain name or external IP address [default: localhost]: " APP_DOMAIN
if [ -z "$APP_DOMAIN" ]; then
APP_DOMAIN="localhost"
fi
read -p "Please set a port for the frontend server [default: 80]: " CLIENT_PORT
if [ -z "$CLIENT_PORT" ]; then
CLIENT_PORT="80"
fi
read -p "Please set a port for the backend server [default: 9000]: " SERVER_PORT
if [ -z "$SERVER_PORT" ]; then
SERVER_PORT="9000"
fi
read -p "Please set a port for the backend WSGI server [default: 8000]: " WSGI_PORT
if [ -z "$WSGI_PORT" ]; then
WSGI_PORT="8000"
fi
read -p "If you want to connect to a database, please enter the database URL [default: none]: " DATABASE_URL
if [ -z "$DATABASE_URL" ]; then
DATABASE_URL="sqlite:///db.sqlite3"
fi
if [[ $(which docker) ]]; then
echo "Docker is already installed"
else
echo "Docker is not installed, installing now..."
sudo apt-get update
sudo apt-get install -y \
ca-certificates \
curl \
gnupg \
lsb-release
sudo mkdir -m 0755 -p /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg
echo \
"deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu \
$(lsb_release -cs) stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
sudo apt-get update
sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
fi
if [[ $(which docker-compose) ]]; then
echo "Docker Compose is already installed"
else
echo "Docker Compose is not installed, installing now..."
sudo curl -L "https://github.com/docker/compose/releases/download/v2.16.0/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose
sudo chmod +x /usr/local/bin/docker-compose
fi
echo "Downloading configuration files..."
sudo curl -L "https://raw.githubusercontent.com/WongSaang/chatgpt-ui/main/docker-compose.yml" -o docker-compose.yml
echo "Pulling images..."
sudo docker-compose pull
echo "Starting services..."
sudo APP_DOMAIN="${APP_DOMAIN}:${SERVER_PORT}" CLIENT_PORT=${CLIENT_PORT} SERVER_PORT=${SERVER_PORT} WSGI_PORT=${WSGI_PORT} DB_URL=${DATABASE_URL} docker-compose up -d
echo "Done"

17
docker-compose.dev.yml Normal file
View File

@@ -0,0 +1,17 @@
version: '3'
services:
client:
platform: linux/x86_64
build: .
environment:
SERVER_DOMAIN: ${SERVER_DOMAIN:-http://web-server}
NUXT_PUBLIC_TYPEWRITER: false
ports:
- '${CLIENT_PORT:-80}:80'
networks:
- chatgpt_network
restart: always
networks:
chatgpt_network:
driver: bridge

16
docker-compose.test.yml Normal file
View File

@@ -0,0 +1,16 @@
version: '3'
services:
client:
platform: linux/x86_64
build: .
environment:
SERVER_DOMAIN: ${SERVER_DOMAIN:-http://web-server}
ports:
- '${CLIENT_PORT:-80}:80'
networks:
- chatgpt_network
restart: always
networks:
chatgpt_network:
driver: bridge

View File

@@ -1,8 +1,58 @@
version: '3'
services:
app:
build:
context: .
dockerfile: ./Dockerfile
client:
platform: linux/x86_64
image: wongsaang/chatgpt-ui-client:latest
environment:
- SERVER_DOMAIN=http://backend-web-server
- DEFAULT_LOCALE=en
# - NUXT_PUBLIC_APP_NAME='ChatGPT UI' # The name of the application
# - NUXT_PUBLIC_TYPEWRITER=true # Whether to enable the typewriter effect, default false
# - NUXT_PUBLIC_TYPEWRITER_DELAY=50 # The delay time of the typewriter effect, default 50ms
depends_on:
- backend-web-server
ports:
- '${APP_PORT:-80}:80'
- '${CLIENT_PORT:-80}:80'
networks:
- chatgpt_ui_network
restart: always
backend-wsgi-server:
platform: linux/x86_64
image: wongsaang/chatgpt-ui-wsgi-server:latest
environment:
- APP_DOMAIN=${APP_DOMAIN:-localhost:9000}
- SERVER_WORKERS=3 # The number of worker processes for handling requests.
- WORKER_TIMEOUT=180 # Workers silent for more than this many seconds are killed and restarted. default 180s
- DB_URL=${DB_URL:-sqlite:///db.sqlite3} # If this parameter is not set, the built-in Sqlite will be used by default. It should be noted that if you do not connect to an external database, the data will be lost after the container is destroyed.
- DJANGO_SUPERUSER_USERNAME=admin # default superuser name
- DJANGO_SUPERUSER_PASSWORD=password # default superuser password
- DJANGO_SUPERUSER_EMAIL=admin@example.com # default superuser email
- ACCOUNT_EMAIL_VERIFICATION=${ACCOUNT_EMAIL_VERIFICATION:-none} # Determines the e-mail verification method during signup choose one of "none", "optional", or "mandatory". Default is "optional". If you don't need to verify the email, you can set it to "none".
# If you want to use the email verification function, you need to configure the following parameters
# - EMAIL_HOST=SMTP server address
# - EMAIL_PORT=SMTP server port
# - EMAIL_HOST_USER=
# - EMAIL_HOST_PASSWORD=
# - EMAIL_USE_TLS=True
# - EMAIL_FROM=no-reply@example.com #Default sender email address
ports:
- '${WSGI_PORT:-8000}:8000'
networks:
- chatgpt_ui_network
restart: always
backend-web-server:
platform: linux/x86_64
image: wongsaang/chatgpt-ui-web-server:latest
environment:
- BACKEND_URL=http://backend-wsgi-server:8000
ports:
- '${SERVER_PORT:-9000}:80'
depends_on:
- backend-wsgi-server
networks:
- chatgpt_ui_network
restart: always
networks:
chatgpt_ui_network:
driver: bridge

56
docs/.vuepress/config.ts Normal file
View File

@@ -0,0 +1,56 @@
import { defineUserConfig, defaultTheme } from 'vuepress'
import {
navbarEn,
navbarZh,
sidebarEn,
sidebarZh,
} from './configs/index.js'
export default defineUserConfig({
title: 'ChatGPT UI',
description: 'A ChatGPT web client',
base: '/chatgpt-ui/',
locales: {
// 键名是该语言所属的子路径
// 作为特例,默认语言可以使用 '/' 作为其路径。
'/': {
lang: 'en-US',
description: 'A ChatGPT web client',
},
'/zh/': {
lang: 'zh-CN',
description: '一个 ChatGPT 的 Web 客户端',
},
},
theme: defaultTheme({
locales: {
'/': {
// navbar
navbar: navbarEn,
// sidebar
sidebar: sidebarEn,
},
'/zh/': {
// navbar
navbar: navbarZh,
selectLanguageName: '简体中文',
selectLanguageText: '选择语言',
selectLanguageAriaLabel: '选择语言',
// sidebar
sidebar: sidebarZh,
// 404 page
notFound: [
'这里什么都没有',
'我们怎么到这来了?',
'这是一个 404 页面',
'看起来我们进入了错误的链接',
],
backToHome: '返回首页',
// a11y
openInNewWindow: '在新窗口打开',
toggleColorMode: '切换颜色模式',
toggleSidebar: '切换侧边栏',
},
},
}),
})

View File

@@ -0,0 +1,2 @@
export * from './navbar/index.js'
export * from './sidebar/index.js'

View File

@@ -0,0 +1,12 @@
import type { NavbarConfig } from '@vuepress/theme-default'
export const navbarEn: NavbarConfig = [
{
text: 'Guide',
link: '/',
},
{
text: 'Changelog',
link: 'https://github.com/WongSaang/chatgpt-ui/releases'
}
]

View File

@@ -0,0 +1,2 @@
export * from './en.js'
export * from './zh.js'

View File

@@ -0,0 +1,12 @@
import type { NavbarConfig } from '@vuepress/theme-default'
export const navbarZh: NavbarConfig = [
{
text: '指南',
link: '/zh/',
},
{
text: '更新日志',
link: 'https://github.com/WongSaang/chatgpt-ui/releases',
}
]

View File

@@ -0,0 +1,17 @@
import type { SidebarConfig } from '@vuepress/theme-default'
export const sidebarEn: SidebarConfig = {
'/': [
{
text: 'Guide',
children: [
'/README.md',
'/guide/quick-start.md',
'/guide/configuration.md',
'/guide/problems.md',
'/guide/development.md',
'/guide/buymeacoffee.md',
],
},
]
}

View File

@@ -0,0 +1,2 @@
export * from './en.js'
export * from './zh.js'

View File

@@ -0,0 +1,17 @@
import type { SidebarConfig } from '@vuepress/theme-default'
export const sidebarZh: SidebarConfig = {
'/zh/': [
{
text: '指南',
children: [
'/zh/README.md',
'/zh/guide/quick-start.md',
'/zh/guide/configuration.md',
'/zh/guide/problems.md',
'/zh/guide/development.md',
'/zh/guide/buymeacoffee.md',
],
},
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

43
docs/README.md Normal file
View File

@@ -0,0 +1,43 @@
# Introduction
ChatGPT UI is an unofficial ChatGPT web client. It supports multiple users, multiple languages, and multiple database connections for persistent data storage, such as Mysql, PostgreSQL, and Sqlite.
This project consists of two parts, the client-side and the server-side:
- Client-side, based on [Nuxt](https://nuxt.com/), project address: [https://github.com/WongSaang/chatgpt-ui](https://github.com/WongSaang/chatgpt-ui)
- Server-side, based on [Django](https://djangoproject.com/), project address: [https://github.com/WongSaang/chatgpt-ui-server](https://github.com/WongSaang/chatgpt-ui-server)
## Features
### Client-side
- User system, supporting user registration, login, password modification, and more.
- Multi-language user interface, supporting multiple languages.
- Persistent data storage, supporting Mysql, PostgreSQL, and Sqlite databases.
- Asynchronous conversation, supporting multiple conversations simultaneously.
- Management of historical conversations.
- Continuous chat, allowing ChatGPT clients to answer questions based on their historical chat records, resulting in better answers.
- Web search capability, allowing ChatGPT to retrieve the latest information.
- Convenient tools, supporting one-click message and code block copying, as well as message editing.
- Common command management, allowing users to store and edit their own common commands.
- PWA, supporting installation to the desktop.
- User Token Usage Statistics.
- Supports configuring multiple API Keys.
### Server-side
- The server-side has an administrative panel.
- User management.
- Conversation and message management.
- Common configurations.
## Original Intention
Since using ChatGPT, it has become a good helper in work. Unfortunately, as we all know, it cannot be accessed in some places. But fortunately, OpenAI has opened up its API, so I started to write a user interface for myself.
> Nothing is difficult if you put your heart into it.
Later, several friends asked me how to use ChatGPT because they didn't have the technical skills. So I started to develop a multi-user system, which can not only be used by myself but also help my family and friends around me.
After the project was open-sourced, many people raised issues and some even submitted PRs, and the project has developed to its current state. I also learned a lot during this process, as I have always believed that helping others is also helping oneself.

View File

@@ -0,0 +1,7 @@
# Donation
> If this project is helpful to you, it is also helping me.
If you want to support me, Buy me a coffee ❤️ [https://www.buymeacoffee.com/WongSaang](https://www.buymeacoffee.com/WongSaang)
![Buy Me A Coffee](/images/bmc_qr.png)

View File

@@ -0,0 +1,80 @@
# Configuration Reference
## Database
By default, the backend uses the built-in Sqlite to store data. If an external database is not connected, the data will be lost after the container is destroyed.
The `chatgpt-ui-wsgi-server` image provides the environment variable `DB_URL` to configure the connection to an external database. The following table shows the link format of the `DB_URL`.
| DB | LINK |
|----------------------|--------------------------------------------------|
| PostgreSQL | postgres://USER:PASSWORD@HOST:PORT/DATABASE_NAME |
| MySQL | mysql://USER:PASSWORD@HOST:PORT/DATABASE_NAME |
| SQLite | sqlite:///PATH |
For example, if I am using PostgreSQL, the configuration is as follows:
```
backend-wsgi-server:
image: wongsaang/chatgpt-ui-wsgi-server:latest
environment:
- DB_URL=postgres://postgres:postgrespw@localhost:49153/chatgpt
```
## Email verification
If you open the user registration feature and need to send email activation links to users, you need to configure the following environment variables in the `wsgi-server` service:
| Parameters | Description | Default |
|----------------------|--------------------------------------------------|-----|
| ACCOUNT_EMAIL_VERIFICATION | E-mail authentication method, optional value: none, optional, mandatory | optional |
| EMAIL_HOST | SMTP server address | smtp.mailgun.org |
| EMAIL_PORT | SMTP server port | 587 |
| EMAIL_HOST_USER | User name | - |
| EMAIL_HOST_PASSWORD | Password | - |
| EMAIL_USE_TLS | Whether to encrypt | True |
| EMAIL_FROM | From email | webmaster@localhost |
## API Proxy
If you are unable to request the OpenAI API address due to network restrictions, you can configure a proxy in the `wsgi-server` service. You will need to search for how to set up a proxy server on your own.
For example:
```
backend-wsgi-server:
image: wongsaang/chatgpt-ui-wsgi-server:latest
environment:
- OPENAI_API_PROXY=https://openai.proxy.com/v1
```
## Backend CSRF whitelist
If you encounter `CSRF verification failed` while accessing the management background, your `APP_DOMAIN` may not be configured correctly. Under the `wsgi-server` service, there is an environment variable `wsgi-server`. Its value should be the address and port of `backend-web-server`, default: `localhost:9000`.
Suppose I have resolved the domain name `chagpt.com` to the server, and my `backend-web-server` service is bound to port 9000. The correct configuration is as follows:
```
backend-wsgi-server:
image: wongsaang/chatgpt-ui-wsgi-server:latest
environment:
- APP_DOMAIN=chagpt.com:9000
```
## Client Configuration
| Parameter | Description | Default Value |
|-----------------------|---------------------------------------------|----------------------------|
| SERVER_DOMAIN | Server Address | http://backend-web-server |
| DEFAULT_LOCALE | Default Language | en |
| NUXT_PUBLIC_APP_NAME | Application Name | ChatGPT UI |
| NUXT_PUBLIC_TYPEWRITER| Enable Typewriter Effect [true/false] | true |
| NUXT_PUBLIC_TYPEWRITER_DELAY | Typewriter Effect Delay in milliseconds | 50 |
## User Registration Control
After deployment, there is an `open_registration` setting under `Chat->Settings` in the admin panel to control whether user registration is allowed. The default value is `True` (allowing user registration). If not needed, please change it to `False`.
## Web Search Function Control
This feature is disabled by default. You can enable it in the admin panel under `Chat->Settings`. There is a setting called `open_web_search`, set its value to `True`.

62
docs/guide/development.md Normal file
View File

@@ -0,0 +1,62 @@
# Development Guide
## Front-end
Required skills: [Vue](https://vuejs.org/), [Nuxt](https://nuxt.com/)
Project address: [https://github.com/WongSaang/chatgpt-ui](https://github.com/WongSaang/chatgpt-ui)
### Environment Setup
Install the latest stable version of node.js. If you need to package it as a docker image, you also need to install docker.
### Install dependencies
```
yarn install
```
### Start development server
```
yarn dev
```
### Build
```
yarn build
```
### Package as a docker image
```
docker build -t image-name:latest .
```
## Back-end
Required skills: [Python](https://www.python.org/), [Django](https://djangoproject.com/)
Project address: [https://github.com/WongSaang/chatgpt-ui-server](https://github.com/WongSaang/chatgpt-ui-server)
### Environment Setup
Install Python, pip/pipenv. If you need to package it as a docker image, you also need to install docker.
### Install dependencies
```
pip install -r requirements.txt
```
### Start development server
```
python manage.py runserver
```
### Package as a docker image
```
docker build -t image-name:latest .
```

13
docs/guide/problems.md Normal file
View File

@@ -0,0 +1,13 @@
# Encountering Issues
## Searching for Issues
If you encounter any issues while using the project, you can search for related keywords on the project's [Issues](https://github.com/WongSaang/chatgpt-ui/issues) page to see if others have faced similar issues and if there are any solutions available.
## Submitting an Issue
If you cannot find a solution, you can communicate with the project maintainers by submitting an issue. [Submit an Issue](https://github.com/WongSaang/chatgpt-ui/issues/new)
**Note**
The title should be clear and concise, and the description should provide as much detail as possible about the issue or suggestion. If possible, it is best to provide reproducible steps and screenshots.

117
docs/guide/quick-start.md Normal file
View File

@@ -0,0 +1,117 @@
# Quick Start
This project provides related docker images for deployment on a VPS or your local computer. Please note that if your network is unable to request the OpenAI API address, you need to configure a proxy. If you want to make it available to other users, it's best to have a domain name and resolve it to the server.
You also need an OpenAI API Key, and there are multiple ways to obtain it online, please search for it yourself.
## Deploying
### Quickly deploy script
**Note: This script has only been verified on Ubuntu Server 22.04 LTS.**
```
bash <(curl -Ls https://raw.githubusercontent.com/WongSaang/chatgpt-ui/main/deployment.sh)
```
### Docker Compose
#### Prepare docker-compose.yml
The project provides a sample `docker-compose.yml`. If you want to customize the configuration, please refer to the [configuration reference](/en/guide/configuration) section.
You can download the `docker-compose.yml` template to your local machine or server by clicking on the link below:
[https://raw.githubusercontent.com/WongSaang/chatgpt-ui/main/docker-compose.yml](https://raw.githubusercontent.com/WongSaang/chatgpt-ui/main/docker-compose.yml)
You can also manually create the `docker-compose.yml` file and copy the following content into the file:
```
version: '3'
services:
client:
platform: linux/x86_64
image: wongsaang/chatgpt-ui-client:latest
environment:
- SERVER_DOMAIN=http://backend-web-server
- DEFAULT_LOCALE=en
# - NUXT_PUBLIC_APP_NAME='ChatGPT UI' # The name of the application
# - NUXT_PUBLIC_TYPEWRITER=true # Whether to enable the typewriter effect, default false
# - NUXT_PUBLIC_TYPEWRITER_DELAY=50 # The delay time of the typewriter effect, default 50ms
depends_on:
- backend-web-server
ports:
- '${CLIENT_PORT:-80}:80'
networks:
- chatgpt_ui_network
restart: always
backend-wsgi-server:
platform: linux/x86_64
image: wongsaang/chatgpt-ui-wsgi-server:latest
environment:
- APP_DOMAIN=${APP_DOMAIN:-localhost:9000}
- SERVER_WORKERS=3 # The number of worker processes for handling requests.
# - DB_URL=postgres://postgres:postgrespw@localhost:49153/chatgpt # If this parameter is not set, the built-in Sqlite will be used by default. It should be noted that if you do not connect to an external database, the data will be lost after the container is destroyed.
- DJANGO_SUPERUSER_USERNAME=admin # default superuser name
- DJANGO_SUPERUSER_PASSWORD=password # default superuser password
- DJANGO_SUPERUSER_EMAIL=admin@example.com # default superuser email
- ACCOUNT_EMAIL_VERIFICATION=${ACCOUNT_EMAIL_VERIFICATION:-none} # Determines the e-mail verification method during signup choose one of "none", "optional", or "mandatory". Default is "optional". If you don't need to verify the email, you can set it to "none".
# If you want to use the email verification function, you need to configure the following parameters
# - EMAIL_HOST=SMTP server address
# - EMAIL_PORT=SMTP server port
# - EMAIL_HOST_USER=
# - EMAIL_HOST_PASSWORD=
# - EMAIL_USE_TLS=True
# - EMAIL_FROM=no-reply@example.com #Default sender email address
ports:
- '${WSGI_PORT:-8000}:8000'
networks:
- chatgpt_ui_network
restart: always
backend-web-server:
platform: linux/x86_64
image: wongsaang/chatgpt-ui-web-server:latest
environment:
- BACKEND_URL=http://backend-wsgi-server:8000
ports:
- '${SERVER_PORT:-9000}:80'
depends_on:
- backend-wsgi-server
networks:
- chatgpt_ui_network
restart: always
networks:
chatgpt_ui_network:
driver: bridge
```
#### Starting the Service
After modifying the configuration as needed, you can start the service by running the following command:
```
docker-compose up --pull always -d
```
This command is used to start the services specified in the Docker Compose configuration. The specific meanings of the parameters are as follows:
- `up`: start the services specified in the Docker Compose configuration.
- `--pull always`: before starting the service each time, the latest version of the image will be pulled from the Docker image repository. This ensures that the image used is always up to date.
- `-d`: run the service in the background. If this parameter is not added, the service will run in the current terminal window until the user manually stops it.
## After Deployment
Access the management panel at `http(s)://your.domain:9000/admin` or `http(s)://123.123.123.123:9000/admin` using the default superuser account:
- username: **admin**
- password: **password**
~~Before starting a chat, you need to add an OpenAI API key. In the management panel, in the "Settings" section, there is a record named `openai_api_key`. Set the value to your API key.~~
In the latest version, a separate API Key management has been added to the admin panel, located under "Provider/Api keys". You can add multiple API Keys here, and the backend program will track the usage of each key's token and balance the usage based on token usage. **To enable this feature, you need to delete the previous "openai_api_key" setting.**
Now you can access the client at `http(s)://your.domain` or `http://123.123.123.123` to start chatting.
🎉🎉🎉 Have fun!

42
docs/zh/README.md Normal file
View File

@@ -0,0 +1,42 @@
# 介绍
ChatGPT UI 是一个非官方的 ChatGPT Web 客户端。它支持多用户多语言多种数据库连接进行数据持久化存储例如Mysql、PostgreSQL 和 Sqlite 等。
本项目项目包括客户端和服务端两部分。
- 客户端,基于 [Nuxt](https://nuxt.com/),项目地址:[https://github.com/WongSaang/chatgpt-ui](https://github.com/WongSaang/chatgpt-ui)
- 服务端,基于 [Django](https://djangoproject.com/),项目地址:[https://github.com/WongSaang/chatgpt-ui-server](https://github.com/WongSaang/chatgpt-ui-server)
## 功能与特性
### 客户端
- 用户系统,支持用户注册、登录、修改密码等。
- 用户界面多语言,支持多种语言。
- 数据持久化,支持 Mysql、PostgreSQL 和 Sqlite 等数据库。
- 异步对话,支持多个对话同时进行。
- 历史对话管理。
- 持续聊天,让 ChatGPT 客户历史聊天记录回答问题,得出更好的答案。
- 网页搜索能力,让 ChatGPT 获取最新信息。
- 便捷的工具,支持一键复制消息和代码块,以及重新编辑消息等。
- 常用指令管理,用户可存储和编辑自己的常用指令。
- PWA支持安装到桌面。
- 用户 Token 使用量统计
- 支持配置多个 API Key
### 服务端
- 服务端拥有一个管理面板
- 用户管理
- 对话和消息管理
- 常用配置
## 初衷
自从使用 ChatGPT ,它已经成为工作中的好帮手。可惜的是,就像大家知道的,它在有些地方无法访问。但好在 OpenAI 开放了 API于是我开始为自己写用户界面。
> 世上无难事,只怕有心人。
后来,有多位朋友询问我怎么样才能使用 ChatGPT因为他们没有技术能力。于是我又着手于多用户系统的开发这样除了自己用还能帮助到身边的亲朋好友。
项目开源后,有很多人提了 issue也有人提了 PR项目就发展到如今的样子。我在这个过程中也学到了很多正如我一直坚信的帮助他人也是帮助自己。

View File

@@ -0,0 +1,7 @@
# 续杯咖啡
> 如果这个项目对您有帮助,这也是在帮助我自己。
如果你想支持我,给我续杯咖啡吧 ❤️ [https://www.buymeacoffee.com/WongSaang](https://www.buymeacoffee.com/WongSaang)
![Buy Me A Coffee](/images/bmc_qr.png)

View File

@@ -0,0 +1,81 @@
# 配置参考
## 数据库
后端默认使用内置的 Sqlite 来存储数据,如果不连接外部数据库,数据将在容器销毁后丢失。
`chatgpt-ui-wsgi-server` 镜像提供环境变量 `DB_URL` 来配置与外部数据库的连接,以下是 `DB_URL` 的链接格式对照表。
| 数据库 | 链接 |
|----------------------|--------------------------------------------------|
| PostgreSQL | postgres://USER:PASSWORD@HOST:PORT/DATABASE_NAME |
| MySQL | mysql://USER:PASSWORD@HOST:PORT/DATABASE_NAME |
| SQLite | sqlite:///PATH |
例如我使用 PostgreSQL则配置如下
```
backend-wsgi-server:
image: wongsaang/chatgpt-ui-wsgi-server:latest
environment:
- DB_URL=postgres://postgres:postgrespw@localhost:49153/chatgpt
```
## 邮箱验证
如果你开放用户注册功能,并需要向用户发送邮箱激活链接,需要在 `wsgi-server` 服务中配置以下环境变量:
| 参数 | 说明 | 默认值 |
|----------------------|--------------------------------------------------|-----|
| ACCOUNT_EMAIL_VERIFICATION | 邮箱验证方式,可选值: none, optional, mandatory | optional |
| EMAIL_HOST | SMTP 服务器地址 | smtp.mailgun.org |
| EMAIL_PORT | SMTP 服务器端口号 | 587 |
| EMAIL_HOST_USER | 用户名 | - |
| EMAIL_HOST_PASSWORD | 密码 | - |
| EMAIL_USE_TLS | 是否加密 | True |
| EMAIL_FROM | 发件邮箱 | webmaster@localhost |
## API 代理
如果您的网络无法请求 OpenAI 的 API 地址,您可以在 `wsgi-server` 服务中配置代理,如何搭建代理服务,需要您自行搜索。
例如:
```
backend-wsgi-server:
image: wongsaang/chatgpt-ui-wsgi-server:latest
environment:
- OPENAI_API_PROXY=https://openai.proxy.com/v1 # 注意,域名后面需要带上 v1
```
## 后端 CSRF 白名单
如果你在访问管理后台的时候遇到 `CSRF verification failed`,可能你的 `APP_DOMAIN` 没有配置对。在 `wsgi-server` 服务下有个环境变量 `wsgi-server`。 它的值应该是 `backend-web-server` 的地址+端口, 默认: `localhost:9000`
假如我把 `chagpt.com` 这个域名解析到了服务器,并且我的 `backend-web-server` 服务绑定了 9000 这个端口。正确的配置如下:
```
backend-wsgi-server:
image: wongsaang/chatgpt-ui-wsgi-server:latest
environment:
- APP_DOMAIN=chagpt.com:9000
```
## 客户端配置
| 参数 | 说明 | 默认值 |
|----------------------|-------------------------------------------|---------------------------|
| SERVER_DOMAIN | 服务端地址 | http://backend-web-server |
| DEFAULT_LOCALE | 默认语言 | en |
| NUXT_PUBLIC_APP_NAME | 应用名称 | ChatGPT UI |
| NUXT_PUBLIC_TYPEWRITER | 是否开启 打字机 效果[true/false]| true |
| NUXT_PUBLIC_TYPEWRITER_DELAY | 打字机效果的延迟时间,单位:毫秒| 50 |
## 用户注册控制
部署完整后,在管理后台的 `Chat->Setting` 下面有 `open_registration` 设置项,用于控制是否开放用户注册。默认是 `True` (允许用户注册),如果不需要,请改成 `False`
## 网页搜索功能控制
该功能默认处于关闭状态,你可以在管理后台的 `Chat->Settings` 中开启它,在 Settings 中有一个 `open_web_search` 的设置项,把它的值设置为 `True`

View File

@@ -0,0 +1,62 @@
# 开发指南
## 前端
所需技能:[Vue](https://vuejs.org/)、[Nuxt](https://nuxt.com/)
项目地址:[https://github.com/WongSaang/chatgpt-ui](https://github.com/WongSaang/chatgpt-ui)
### 环境准备
安装最新稳定版 node.js如果需要打包成 docker 镜像,还需要安装 docker。
### 安装依赖
```
yarn install
```
### 启动开发服务
```
yarn dev
```
### 构建
```
yarn build
```
### 打包成 docker 镜像
```
docker build -t image-name:latest .
```
## 后端
所需技能:[Python](https://www.python.org/)、[Django](https://djangoproject.com/)
项目地址:[https://github.com/WongSaang/chatgpt-ui-server](https://github.com/WongSaang/chatgpt-ui-server)
### 环境准备
安装Python、pip/pipenv如果需要打包成 docker 镜像,还需要安装 docker。
### 安装依赖
```
pip install -r requirements.txt
```
### 启动开发服务
```
python manage.py runserver
```
### 打包成 docker 镜像
```
docker build -t image-name:latest .
```

13
docs/zh/guide/problems.md Normal file
View File

@@ -0,0 +1,13 @@
# 遇到问题
## 搜索问题
当你在使用项目时,如果遇到了问题,可以在项目的 [Issues](https://github.com/WongSaang/chatgpt-ui/issues) 页面搜索相关的关键词,看看其他人是否遇到过相同的问题以及解决方案。
## 提 issue
如果没有找到解决方案,可以通过提交 Issue 来与项目维护者交流。[提交Issue](https://github.com/WongSaang/chatgpt-ui/issues/new)
**注意**
标题应该简单明了,描述应该尽可能详细地描述问题或者建议。如果可能,最好提供复现步骤和截图。

View File

@@ -0,0 +1,117 @@
# 快速开始
本项目提供了相关的 docker 镜像,你需要一个 vps 来部署,当然你也可以在本地的电脑上部署。需要注意的是,如果你的网络无法请求 OpenAI 的 API 地址,您需要配置代理。如果你想开放给其他用户使用,最好还需要一个域名,并将域名解析到服务器。
您还需要一个 OpenAI 的API Key网上有获取多种方案请自行搜索。
## 部署
### 快速部署脚本
*对于技术知识了解不多的选手,如果你看不懂下面的内容,可以看我之前写的博客文章[《一行命令部署自己的ChatGPT客户端》](https://wongsnotes.com/p/deploying-your-own-chatgpt-client-with-one-line-of-command/)*
**注意:此脚本目前仅在 Ubuntu Server 22.04 LTS 上验证过。**
```
bash <(curl -Ls https://raw.githubusercontent.com/WongSaang/chatgpt-ui/main/deployment.sh)
```
### Docker Compose
#### 准备 docker-compose.yml
项目中提供了一个 `docker-compose.yml` 示例,如果你想自定义配置,请看 [配置参考](/zh/guide/configuration) 部分。
你可以通过下方链接下载 `docker-compose.yml` 模板到本地或服务器:
[https://raw.githubusercontent.com/WongSaang/chatgpt-ui/main/docker-compose.yml](https://raw.githubusercontent.com/WongSaang/chatgpt-ui/main/docker-compose.yml)
也可以手动创建 `docker-compose.yml` 文件,然后复制下面的内容到文件中:
```
version: '3'
services:
client:
image: wongsaang/chatgpt-ui-client:latest
environment:
- SERVER_DOMAIN=http://backend-web-server
- DEFAULT_LOCALE=zh
# - NUXT_PUBLIC_APP_NAME='ChatGPT UI' # APP 名称
# - NUXT_PUBLIC_TYPEWRITER=true # 是否开启 打字机 效果
# - NUXT_PUBLIC_TYPEWRITER_DELAY=50 # 打字机效果的延迟时间单位毫秒默认50
depends_on:
- backend-web-server
ports:
- '80:80'
networks:
- chatgpt_ui_network
backend-wsgi-server:
image: wongsaang/chatgpt-ui-wsgi-server:latest
environment:
- APP_DOMAIN=${APP_DOMAIN:-localhost:9000} # CSRF 白名单,在这里设置为 chatgpt-ui-web-server 的地址+端口, 默认: localhost:9000
- SERVER_WORKERS=3 # gunicorn 的工作进程数,默认为 3
#- DB_URL=postgres://postgres:postgrespw@localhost:49153/chatgpt # 连接外部数据库,如果不设置这个参数,则默认使用内置的 Sqlite。需要注意的是如果不连接外部数据库数据将在容器销毁后丢失。链接格式请看下面的 DB_URL 格式对照表
#- OPENAI_API_PROXY=https://openai.proxy.com/v1 # https://api.openai.com/v1 的代理地址
- DJANGO_SUPERUSER_USERNAME=admin # 默认超级用户
- DJANGO_SUPERUSER_PASSWORD=password # 默认超级用户的密码
- DJANGO_SUPERUSER_EMAIL=admin@example.com # 默认超级用户邮箱
- ACCOUNT_EMAIL_VERIFICATION=none # 邮箱验证方式,可选值: none, optional, mandatory. 默认为 optional。如果你不需要验证用户的邮箱可以设置为 none。
# 如果您想使用电子邮件验证功能,需要配置以下参数:
# - EMAIL_HOST=SMTP server address
# - EMAIL_PORT=SMTP server port
# - EMAIL_HOST_USER=
# - EMAIL_HOST_PASSWORD=
# - EMAIL_USE_TLS=True
# - EMAIL_FROM=no-reply@example.com #默认发件邮箱地址
ports:
- '8000:8000'
networks:
- chatgpt_ui_network
backend-web-server:
image: wongsaang/chatgpt-ui-web-server:latest
environment:
- BACKEND_URL=http://backend-wsgi-server:8000
ports:
- '9000:80'
depends_on:
- backend-wsgi-server
networks:
- chatgpt_ui_network
networks:
chatgpt_ui_network:
driver: bridge
```
#### 启动服务
你可以自行修改配置后,运行下面的命令来启动服务。
```
docker-compose up --pull always -d
```
这个命令用于启动 Docker Compose 配置中的服务。具体的参数含义如下:
- `up`:启动 Docker Compose 配置中的服务。
- `--pull always`:每次启动服务前,都会从 Docker 镜像仓库中拉取最新版本的镜像。这样可以确保使用的镜像始终是最新的。
- `-d`:在后台运行服务。如果不加这个参数,服务会在当前终端窗口中运行,直到用户手动停止服务。
## 部署完成之后
访问 `http(s)://your.domain:9000/admin` 或 IP `http(s)://123.123.123.123:9000/admin` 登录管理面板。
默认超级用户: **admin**
默认密码: **password**
~~在可以开始聊天之前,您需要添加一个 OpenAI 的 API 密钥。在管理面板的设置模型中,有一个名称为 `openai_api_key` 的记录,将值设置为您的 API 密钥。~~
在最新版本中,管理面板增加了一个独立的 API Key 的管理,位于管理面板的 `Provider/ Api keys`。你可以在这里添加多个 API Key后端程序会统计每个 Key 的 token 使用量,并根据 token 使用量来平衡使用 Key。**想要这个功能生效,需要删除之前的`openai_api_key`设置**
现在可以访问客户端地址 `http(s)://your.domain` 或 IP `http://123.123.123.123` 开始聊天。
🎉🎉🎉 祝开心!

96
lang/en-US.json Normal file
View File

@@ -0,0 +1,96 @@
{
"signIn":"Sign In",
"signUp":"Sign Up",
"username":"User Name",
"password":"Password",
"Username is required":"Username is required",
"Password is required":"Password is required",
"Create your account":"Create your account",
"createAccount":"Create Account",
"email":"E-mail",
"Sign in instead":"Sign in instead",
"Please enter your username":"Please enter your username",
"Username must be at least 4 characters":"Username must be at least 4 characters",
"Please enter your e-mail address":"Please enter your e-mail address",
"E-mail address must be valid":"E-mail address must be valid",
"Please enter your password":"Please enter your password",
"Password must be at least 8 characters":"Password must be at least 8 characters",
"Please confirm your password":"Please confirm your password",
"welcomeTo": "Welcome to",
"language": "Language",
"setApiKey": "Set API Key",
"setOpenAIApiKey": "Set OpenAI API Key",
"openAIApiKey": "OpenAI API Key",
"getAKey": "Get a key",
"openAIModels": "OpenAI Models",
"aboutTheModels": "About the models",
"saveAndClose": "Save & Close",
"pleaseSelectAtLeastOneModelDot": "Please select at least one model.",
"writeAMessage": "Write a message",
"frequentlyPrompts": "Frequently prompts",
"addPrompt": "Add prompt",
"titlePrompt": "Title",
"addNewPrompt": "Add a new prompt",
"pressEnterToSendYourMessageOrShiftEnterToAddANewLine": "Press Enter to send your message or Shift+Enter to add a new line",
"lightMode": "Light Mode",
"darkMode": "Dark Mode",
"followSystem": "Follow system",
"themeMode": "Theme Mode",
"feedback": "Feedback",
"newConversation": "New conversation",
"defaultConversationTitle": "Unnamed",
"clearConversations": "Clear conversations",
"modelParameters": "Model Parameters",
"model": "Model",
"temperature": "Temperature",
"topP": "Top P",
"frequencyPenalty": "Frequency Penalty",
"presencePenalty": "Presence Penalty",
"maxTokens": "Max Tokens",
"roles": {
"me": "Me",
"ai": "AI"
},
"edit": "Edit",
"copy": "Copy",
"copied": "Copied",
"delete": "Delete",
"signOut": "Sign out",
"resetPassword": "Reset password",
"submit": "Submit",
"agree": "Agree",
"newPassword": "New password",
"currentPassword": "Current password",
"confirmPassword": "Confirm password",
"yourPasswordHasBeenReset": "Your password has been reset",
"nowYouNeedToSignInAgain": "Now you need to sign in again",
"webSearch": "Web Search",
"webSearchDefaultPrompt": "Web search results:\n\n[web_results]\nCurrent date: [current_date]\n\nInstructions: Using the provided web search results, write a comprehensive reply to the given query. Make sure to cite results using [[number](URL)] notation after the reference. If the provided search results refer to multiple subjects with the same name, write separate answers for each subject.\nQuery: [query]",
"genTitlePrompt": "Generate a short title for the following content, no more than 10 words. \n\nContent: ",
"maxTokenTips1": "The maximum context length of the current model is",
"maxTokenTips2": "token, which includes the length of the prompt and the length of the generated text. The `Max Tokens` here refers to the length of the generated text. Therefore, you should leave some space for your prompt and not set it too large or to the maximum.",
"frugalMode": "Frugal mode",
"frugalModeTip": "Activate frugal mode, the client will not send historical messages to ChatGPT, which can save token consumption. If you want ChatGPT to understand the context of the conversation, please turn off frugal mode.",
"welcomeScreen": {
"introduction1": "is an unofficial client for ChatGPT, but uses the official OpenAI API.",
"introduction2": "You will need an OpenAI API Key before you can use this client.",
"examples": {
"title": "Examples",
"item1": "\"Explain quantum computing in simple terms\"",
"item2": "\"Got any creative ideas for a 10 year olds birthday?\"",
"item3": "\"How do I make an HTTP request in Javascript?\""
},
"capabilities": {
"title": "Capabilities",
"item1": "Remembers what user said earlier in the conversation",
"item2": "Allows user to provide follow-up corrections",
"item3": "Trained to decline inappropriate requests"
},
"limitations": {
"title": "Limitations",
"item1": "May occasionally generate incorrect information",
"item2": "May occasionally produce harmful instructions or biased content",
"item3": "Limited knowledge of world and events after 2021"
}
}
}

96
lang/ru-RU.json Normal file
View File

@@ -0,0 +1,96 @@
{
"signIn":"Sign In",
"signUp":"Sign Up",
"username":"User Name",
"password":"Password",
"Username is required":"Username is required",
"Password is required":"Password is required",
"Create your account":"Create your account",
"createAccount":"Create Account",
"email":"E-mail",
"Sign in instead":"Sign in instead",
"Please enter your username":"Please enter your username",
"Username must be at least 4 characters":"Username must be at least 4 characters",
"Please enter your e-mail address":"Please enter your e-mail address",
"E-mail address must be valid":"E-mail address must be valid",
"Please enter your password":"Please enter your password",
"Password must be at least 8 characters":"Password must be at least 8 characters",
"Please confirm your password":"Please confirm your password",
"welcomeTo": "Добро пожаловать в",
"language": "Язык",
"setApiKey": "Установить ключ API",
"setOpenAIApiKey": "Установить ключ API OpenAI",
"openAIApiKey": "Ключ API OpenAI",
"getAKey": "Получить ключ",
"openAIModels": "Модели OpenAI",
"aboutTheModels": "О моделях",
"saveAndClose": "Сохранить & Закрыть",
"pleaseSelectAtLeastOneModelDot": "Выберите хотя бы одну модель.",
"writeAMessage": "Напишите сообщение",
"frequentlyPrompts": "Список подсказок",
"addPrompt": "Добавить подсказку",
"titlePrompt": "Заголовок",
"addNewPrompt": "Добавитьте новую подсказку",
"pressEnterToSendYourMessageOrShiftEnterToAddANewLine": "Нажмите Enter, чтобы отправить сообщение, или Shift+Enter, чтобы добавить новую строку.",
"lightMode": "Светлая",
"darkMode": "Темная",
"followSystem": "Системная",
"themeMode": "Тема",
"feedback": "Обратная связь",
"newConversation": "Новый чат",
"defaultConversationTitle": "Безымянный",
"clearConversations": "Очистить чаты",
"modelParameters": "Параметры модели",
"model": "Модель",
"temperature": "Temperature",
"topP": "Top P",
"frequencyPenalty": "Frequency Penalty",
"presencePenalty": "Presence Penalty",
"maxTokens": "Max Tokens",
"roles": {
"me": "Я",
"ai": "AI"
},
"edit": "Редактировать",
"copy": "Копировать",
"copied": "Скопировано",
"delete": "Удалить",
"signOut": "Выход",
"resetPassword": "Сбросить пароль",
"submit": "Отправить",
"agree": "Согласен",
"newPassword": "Новый пароль",
"currentPassword": "Текущий пароль",
"confirmPassword": "Подтвердите пароль",
"yourPasswordHasBeenReset": "Ваш пароль был сброшен",
"nowYouNeedToSignInAgain": "Теперь вам нужно снова войти в систему",
"webSearch": "Поиск в интернете",
"webSearchDefaultPrompt": "Результаты веб-поиска:\n\n[web_results]\nТекущая дата: [current_date]\n\nИнструкции: Используя предоставленные результаты веб-поиска, напишите развернутый ответ на заданный запрос. Обязательно цитируйте результаты, используя обозначение [[number](URL)] после ссылки. Если предоставленные результаты поиска относятся к нескольким темам с одинаковым названием, напишите отдельные ответы для каждой темы.\nЗапрос: [query]",
"genTitlePrompt": "Придумайте короткий заголовок для следующего содержания, не более 10 слов. \n\nСодержание: ",
"maxTokenTips1": "The maximum context length of the current model is",
"maxTokenTips2": "token, which includes the length of the prompt and the length of the generated text. The `Max Tokens` here refers to the length of the generated text. Therefore, you should leave some space for your prompt and not set it too large or to the maximum.",
"frugalMode": "Frugal mode",
"frugalModeTip": "Activate frugal mode, the client will not send historical messages to ChatGPT, which can save token consumption. If you want ChatGPT to understand the context of the conversation, please turn off frugal mode.",
"welcomeScreen": {
"introduction1": "является неофициальным клиентом для ChatGPT, но использует официальный API OpenAI.",
"introduction2": "Вам понадобится ключ API OpenAI, прежде чем вы сможете использовать этот клиент.",
"examples": {
"title": "Примеры",
"item1": "\"Объясни, что такое квантовые вычисления простыми словами\"",
"item2": "\"Предложи несколько креативных идей для дня рождения 10-летнего ребенка?\"",
"item3": "\"Как сделать HTTP-запрос в Javascript?\""
},
"capabilities": {
"title": "Возможности",
"item1": "Помнит, что пользователь сказал ранее в разговоре",
"item2": "Позволяет пользователю вносить последующие исправления",
"item3": "Научен отклонять неуместные запросы"
},
"limitations": {
"title": "Ограничения",
"item1": "Иногда может генерировать неверную информацию",
"item2": "Иногда может создавать вредные инструкции или предвзятый контент",
"item3": "Ограниченное знание мира и событий после 2021 года"
}
}
}

104
lang/zh-CN.json Normal file
View File

@@ -0,0 +1,104 @@
{
"invitation code":"邀请码",
"Please enter your code":"请填写邀请码",
"signIn":"登录",
"signUp":"注册",
"username":"用户名",
"password":"密码",
"Username is required":"请填写用户名",
"Password is required":"请填写密码",
"Create your account":"创建你的账号",
"createAccount":"创建账号",
"email":"邮箱",
"Sign in instead":"返回登录",
"Please enter your username":"请输入你的用户名",
"Username must be at least 4 characters":"用户名至少四个字符",
"Please enter your e-mail address":"请输入你的电子邮箱",
"E-mail address must be valid":"电子邮箱地址格式不正确",
"Please enter your password":"请输入你的密码",
"Password must be at least 8 characters":"密码至少八个字符",
"Please confirm your password":"请输入确认密码",
"Something went wrong. Please try again.":"网络错误请稍后重试",
"This password is too common.":"密码过于简单",
"This password is entirely numeric.":"密码不能全是数字",
"Your registration is successful":"恭喜你,注册成功!",
"You can now":"现在你可以",
"to your account.":"你的账号了。",
"welcomeTo": "欢迎来到",
"language": "语言",
"setApiKey": "设置API密钥",
"setOpenAIApiKey": "设置OpenAI的API密钥",
"openAIApiKey": "OpenAI的API密钥",
"getAKey": "获取钥匙",
"openAIModels": "OpenAI模型",
"aboutTheModels": "关于模型",
"saveAndClose": "保存并关闭",
"pleaseSelectAtLeastOneModelDot": "请至少选择一个模型",
"writeAMessage": "输入信息",
"frequentlyPrompts": "Frequently prompts",
"addPrompt": "Add prompt",
"titlePrompt": "Title",
"addNewPrompt": "Add a new prompt",
"pressEnterToSendYourMessageOrShiftEnterToAddANewLine": "按回车键发送您的信息或按Shift+Enter键添加新行",
"lightMode": "明亮模式",
"darkMode": "暗色模式",
"followSystem": "跟随系统",
"themeMode": "主题模式",
"feedback": "反馈",
"newConversation": "新的对话",
"defaultConversationTitle": "未命名",
"clearConversations": "清除对话",
"modelParameters": "模型参数",
"model": "模型",
"temperature": "Temperature",
"topP": "Top P",
"frequencyPenalty": "Frequency Penalty",
"presencePenalty": "Presence Penalty",
"maxTokens": "Max Tokens",
"roles": {
"me": "我",
"ai": "AI"
},
"edit": "编辑",
"copy": "复制",
"copied": "已复制",
"delete": "删除",
"signOut": "退出登录",
"resetPassword": "重置密码",
"submit": "提交",
"agree": "同意",
"newPassword": "新密码",
"currentPassword": "当前密码",
"confirmPassword": "确认密码",
"yourPasswordHasBeenReset": "您的密码已重置",
"nowYouNeedToSignInAgain": "现在您需要再次登录",
"webSearch": "网页搜索",
"webSearchDefaultPrompt": "网络搜索结果:\n\n[web_results]\n当前日期[current_date]\n\n说明使用提供的网络搜索结果对给定的查询写出全面的回复。确保在引用参考文献后使用 [[number](URL)] 符号进行引用结果. 如果提供的搜索结果涉及到多个具有相同名称的主题,请针对每个主题编写单独的答案。\n查询[query]",
"genTitlePrompt": "为以下内容生成一个不超过10个字的简短标题。 \n\n内容: ",
"maxTokenTips1": "当前模型的最大上下文长度为",
"maxTokenTips2": "个 token它包括了指令的长度和生成的文本长度。此处的最大 token 数量是指生成的文本长度。所以您应该为您的指令预留一些空间,不宜设置过大或拉满。",
"frugalMode": "节俭模式",
"frugalModeTip": "开启节俭模式客户端不会把历史消息发送给ChatGPT可以节省 token 的消耗。如果你想让 ChatGPT 了解对话的上下文,请关闭节俭模式。",
"welcomeScreen": {
"introduction1": "是一个非官方的ChatGPT客户端但使用OpenAI的官方API",
"introduction2": "在使用本客户端之前您需要一个OpenAI API密钥。",
"examples": {
"title": "例子",
"item1": "\"用简单的语言解释量子计算\"",
"item2": "\"为10岁的孩子过生日有什么创造性的想法吗\"",
"item3": "\"我如何在Javascript中进行HTTP请求\""
},
"capabilities": {
"title": "能力",
"item1": "记得用户在谈话中早先说过的话",
"item2": "允许用户提供后续更正",
"item3": "经过培训,可以拒绝不适当的请求"
},
"limitations": {
"title": "局限",
"item1": "偶尔可能会产生不正确的信息",
"item2": "可能偶尔会产生有害的指示或有偏见的内容",
"item3": "对2021年以后的世界和事件了解有限"
}
}
}

8
layouts/default.vue Normal file
View File

@@ -0,0 +1,8 @@
<template>
<v-app
:theme="$colorMode.value"
>
<NavigationDrawer />
<slot />
</v-app>
</template>

7
layouts/vuetifyApp.vue Normal file
View File

@@ -0,0 +1,7 @@
<template>
<v-app
:theme="$colorMode.value"
>
<slot />
</v-app>
</template>

18
middleware/auth.ts Normal file
View File

@@ -0,0 +1,18 @@
export default defineNuxtRouteMiddleware(async (to, from) => {
const user = useUser()
const signInPath = '/account/signin'
if (!user.value && to.path !== signInPath) {
const { error, data} = await fetchUser()
if (error.value) {
return navigateTo({
path: signInPath,
query: {
callback: encodeURIComponent(to.fullPath)
}
})
} else {
setUser(data.value)
}
}
})

21
nginx.conf Normal file
View File

@@ -0,0 +1,21 @@
server {
listen 80;
listen [::]:80;
server_name localhost;
root /app;
location / {
index index.html;
try_files $uri $uri/ /index.html;
}
location /api/
{
proxy_pass ${SERVER_DOMAIN};
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}

View File

@@ -1,9 +1,8 @@
// https://nuxt.com/docs/api/configuration/nuxt-config
const appName = 'ChatGPT UI'
const appName = process.env.NUXT_PUBLIC_APP_NAME ?? 'ChatGPT UI'
export default defineNuxtConfig({
dev: false,
ssr: false,
debug: process.env.NODE_ENV !== 'production',
ssr: process.env.SSR !== 'false',
app: {
head: {
title: appName,
@@ -11,7 +10,10 @@ export default defineNuxtConfig({
},
runtimeConfig: {
public: {
appName: appName
appName: appName,
typewriter: false,
typewriterDelay: 50,
customApiKey: false
}
},
build: {
@@ -22,5 +24,48 @@ export default defineNuxtConfig({
'material-design-icons-iconfont/dist/material-design-icons.css',
'highlight.js/styles/panda-syntax-dark.css',
],
modules: ['@nuxtjs/color-mode']
modules: [
'@kevinmarrec/nuxt-pwa',
'@nuxtjs/color-mode',
'@nuxtjs/i18n'
],
pwa: {
manifest: {
name: appName,
short_name: appName,
description: 'A ChatGPT web Client'
},
workbox: {
enabled: process.env.DEBUT_PWA === 'true',
}
},
i18n: {
strategy: 'no_prefix',
locales: [
{
code: 'en',
iso: 'en-US',
name: 'English',
file: 'en-US.json',
},
{
code: 'zh-CN',
iso: 'zh-CN',
name: '简体中文',
file: 'zh-CN.json',
},
{
code: 'ru',
iso: 'ru-RU',
name: 'Русский',
file: 'ru-RU.json',
}
],
lazy: true,
langDir: 'lang',
defaultLocale: process.env.DEFAULT_LOCALE || 'en',
vueI18n: {
fallbackLocale: 'en',
},
}
})

View File

@@ -5,20 +5,27 @@
"dev": "nuxt dev",
"generate": "nuxt generate",
"preview": "nuxt preview",
"postinstall": "nuxt prepare"
"postinstall": "nuxt prepare",
"docs:dev": "vuepress dev docs",
"docs:build": "vuepress build docs"
},
"devDependencies": {
"@kevinmarrec/nuxt-pwa": "^0.17.0",
"@nuxtjs/color-mode": "^3.2.0",
"@nuxtjs/i18n": "^8.0.0-beta.9",
"material-design-icons-iconfont": "^6.7.0",
"nuxt": "^3.1.2"
"nuxt": "^3.3.3",
"vuepress": "^2.0.0-beta.61"
},
"dependencies": {
"@keyv/sqlite": "^3.6.4",
"@microsoft/fetch-event-source": "^2.0.1",
"@waylaidwanderer/chatgpt-api": "^1.12.2",
"copy-to-clipboard": "^3.3.3",
"highlight.js": "^11.7.0",
"http-proxy-middleware": "3.0.0-beta.1",
"is-mobile": "^3.1.1",
"marked": "^4.2.12",
"markdown-it": "^13.0.1",
"nanoid": "^4.0.1",
"vuetify": "^3.0.6"
}
},
"license": "MIT"
}

View File

@@ -0,0 +1,90 @@
<script setup>
definePageMeta({
layout: 'vuetify-app',
middleware: ['auth']
})
const route = useRoute()
const sending = ref(false)
const resent = ref(false)
const errorMsg = ref(null)
const user = useUser()
const resendEmail = async () => {
errorMsg.value = null
sending.value = true
const { data, error } = await useFetch('/api/account/registration/resend-email/', {
method: 'POST',
})
if (error.value) {
errorMsg.value = 'Something went wrong. Please try again later.'
} else {
resent.value = true
}
sending.value = false
}
onNuxtReady(() => {
if (route.query.resend) {
resendEmail()
}
})
</script>
<template>
<v-card
class="h-100vh"
>
<v-container>
<v-row>
<v-col
sm="9"
offset-sm="1"
md="8"
offset-md="2"
>
<v-card
class="mt-20vh"
elevation="0"
>
<div class="text-center">
<div v-if="route.query.email_verification_required && route.query.email_verification_required === 'none'">
<h2 class="text-h4">{{$('Your registration is successful')}}</h2>
<p class="mt-5">
{{$('You can now')}} <NuxtLink to="/account/signin">{{$('signIn')}}</NuxtLink> {{$t('to your account.')}}
</p>
</div>
<div v-else>
<h2 class="text-h4">Verify your email</h2>
<p class="mt-5">
We've sent a verification email to <strong>{{ user.email }}</strong>. <br>
Please check your inbox and click the link to verify your email address.
</p>
<p v-if="errorMsg"
class="text-red"
>{{ errorMsg }}</p>
<v-btn
variant="text"
class="mt-5"
color="primary"
:loading="sending"
@click="resendEmail"
:disabled="resent"
>
{{ resent ? 'Resent' : 'Resend email'}}
</v-btn>
</div>
</div>
</v-card>
</v-col>
</v-row>
</v-container>
</v-card>
</template>
<style scoped>
.h-100vh {
height: 100vh;
}
.mt-20vh {
margin-top: 20vh;
}
</style>

View File

@@ -0,0 +1,175 @@
<script setup>
definePageMeta({
middleware: ["auth"]
})
const formData = ref({
old_password: '',
new_password1: '',
new_password2: ''
})
const formRules = ref({
old_password: [
v => !!v || 'Current password is required'
],
new_password1: [
v => !!v || 'New password is required'
],
new_password2: [
v => !!v || 'Confirm password is required',
v => v === formData.value.new_password1 || 'Passwords do not match'
]
})
const fieldErrors = ref({
old_password: '',
new_password1: '',
new_password2: '',
})
const errorMsg = ref(null)
const resetForm = ref(null)
const valid = ref(true)
const submitting = ref(false)
const route = useRoute()
const passwordInputType = ref('password')
const signOut = async () => {
const { data, error } = await useFetch('/api/account/logout/', {
method: 'POST'
})
if (!error.value) {
await logout()
}
}
const submit = async () => {
errorMsg.value = null
const { valid } = await resetForm.value.validate()
if (valid) {
submitting.value = true
const { data, error } = await useFetch('/api/account/password/change/', {
method: 'POST',
body: JSON.stringify(formData.value)
})
submitting.value = false
if (error.value) {
if (error.value.status === 400) {
for (const key in formData.value) {
if (error.value.data[key]) {
fieldErrors.value[key] = error.value.data[key][0]
}
}
if (error.value.data.non_field_errors) {
errorMsg.value = error.value.data.non_field_errors[0]
}
} else {
if (error.value.data.detail) {
errorMsg.value = error.value.data.detail
} else {
errorMsg.value = 'Something went wrong. Please try again.'
}
}
} else {
successDialog.value = true
}
}
}
const handleFieldUpdate = (field) => {
fieldErrors.value[field] = ''
}
const successDialog = ref(false)
</script>
<template>
<v-card
style="height: 100vh"
>
<v-container>
<v-row>
<v-col
sm="9"
offset-sm="1"
md="6"
offset-md="3"
>
<v-card
class="mt-15"
elevation="0"
>
<div class="text-center text-h4">{{ $t('resetPassword') }}</div>
<v-card-text>
<v-form ref="resetForm">
<v-text-field
v-model="formData.old_password"
:rules="formRules.old_password"
:error-messages="fieldErrors.old_password"
@update:modelValue="handleFieldUpdate('old_password')"
:label="$t('currentPassword')"
variant="underlined"
clearable
></v-text-field>
<v-text-field
v-model="formData.new_password1"
:rules="formRules.new_password1"
:error-messages="fieldErrors.new_password1"
@update:modelValue="handleFieldUpdate('new_password1')"
:label="$t('newPassword')"
variant="underlined"
clearable
></v-text-field>
<v-text-field
v-model="formData.new_password2"
:rules="formRules.new_password2"
:error-messages="fieldErrors.new_password2"
@update:modelValue="handleFieldUpdate('new_password2')"
:label="$t('confirmPassword')"
variant="underlined"
clearable
></v-text-field>
</v-form>
<div v-if="errorMsg" class="text-red">{{ errorMsg }}</div>
<div
class="mt-5 d-flex justify-space-between"
>
<v-btn
block
color="primary"
:loading="submitting"
@click="submit"
size="large"
>{{ $t('submit') }}</v-btn>
</div>
</v-card-text>
</v-card>
</v-col>
</v-row>
</v-container>
</v-card>
<v-dialog
v-model="successDialog"
persistent
width="auto"
>
<v-card>
<v-card-title class="text-h5">
{{ $t('yourPasswordHasBeenReset') }}
</v-card-title>
<v-card-text>{{ $t('nowYouNeedToSignInAgain') }}</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn
color="green-darken-1"
variant="text"
@click="signOut"
>
{{ $t('agree') }}
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>

119
pages/account/signin.vue Normal file
View File

@@ -0,0 +1,119 @@
<template>
<v-card
style="height: 100vh"
>
<v-container>
<SettingsLanguages/>
<v-row>
<v-col
sm="9"
offset-sm="1"
md="6"
offset-md="3"
>
<v-card
class="mt-15"
elevation="0"
>
<div class="text-center text-h4">{{$t('signIn')}}</div>
<v-card-text>
<v-form ref="signInForm">
<v-text-field
v-model="formData.username"
:rules="formRules.username"
:label="$t('username')"
variant="underlined"
clearable
></v-text-field>
<v-text-field
v-model="formData.password"
:rules="formRules.password"
:label="$t('password')"
variant="underlined"
@keyup.enter="submit"
clearable
:type="passwordInputType"
:append-inner-icon="passwordInputType === 'password' ? 'visibility' : 'visibility_off'"
@click:append-inner="passwordInputType = passwordInputType === 'password' ? 'text' : 'password'"
></v-text-field>
</v-form>
<div v-if="errorMsg" class="text-red">{{ errorMsg }}</div>
<div
class="mt-5 d-flex justify-space-between"
>
<v-btn
@click="navigateTo('/account/signup')"
variant="text"
color="primary"
>{{$t('createAccount')}}</v-btn>
<v-btn
color="primary"
:loading="submitting"
@click="submit"
size="large"
>{{$t('signIn')}}</v-btn>
</div>
</v-card-text>
</v-card>
</v-col>
</v-row>
</v-container>
</v-card>
</template>
<script setup>
import {useUser} from "~/composables/states";
const { $i18n } = useNuxtApp()
definePageMeta({
layout: 'vuetify-app'
})
const formData = ref({
username: '',
password: ''
})
const formRules = ref({
username: [
v => !!v || $i18n.t('Username is required')
],
password: [
v => !!v || $i18n.t('Password is required')
]
})
const errorMsg = ref(null)
const signInForm = ref(null)
const submitting = ref(false)
const route = useRoute()
const passwordInputType = ref('password')
const submit = async () => {
errorMsg.value = null
const { valid } = await signInForm.value.validate()
if (valid) {
submitting.value = true
const { data, error } = await useFetch('/api/account/login/', {
method: 'POST',
body: JSON.stringify(formData.value)
})
submitting.value = false
if (error.value) {
if (error.value.status === 400) {
if (error.value.data.non_field_errors) {
errorMsg.value = error.value.data.non_field_errors[0]
}
} else {
errorMsg.value = 'Something went wrong. Please try again.'
}
} else {
setUser(data.value.user)
const callback = route.query.callback ? decodeURIComponent(route.query.callback) : '/'
await navigateTo(callback)
}
}
}
</script>

189
pages/account/signup.vue Normal file
View File

@@ -0,0 +1,189 @@
<script setup>
const { $i18n } = useNuxtApp()
definePageMeta({
layout: 'vuetify-app'
})
const formData = ref({
username: '',
email: '',
password1: '',
password2: '',
code:'',
})
const fieldErrors = ref({
username: '',
email: '',
password1: '',
password2: '',
code:'',
})
const formRules = ref({
username: [
v => !!v || $i18n.t('Please enter your username'),
v => v.length >= 4 || $i18n.t('Username must be at least 4 characters')
],
email: [
v => !!v || $i18n.t('Please enter your e-mail address'),
v => /.+@.+\..+/.test(v) || $i18n.t('E-mail address must be valid')
],
password1: [
v => !!v || $i18n.t('Please enter your password'),
v => v.length >= 8 || $i18n.t('Password must be at least 8 characters')
],
password2: [
v => !!v || $i18n.t('Please confirm your password'),
v => v.length >= 8 || $i18n.t('Password must be at least 8 characters'),
v => v === formData.value.password1 || $i18n.t('Confirm password must match password')
],
code: [
v => !!v || $i18n.t('Please enter your code'),
],
})
const submitting = ref(false)
const errorMsg = ref(null)
const signUpForm = ref(null)
const submit = async () => {
errorMsg.value = null
const { valid } = await signUpForm.value.validate()
if (valid) {
submitting.value = true
const { data, error } = await useFetch('/api/account/registration/', {
method: 'POST',
body: JSON.stringify(formData.value)
})
console.log(error.value)
if (error.value) {
if (error.value.status === 400) {
for (const key in formData.value) {
if (error.value.data[key]) {
fieldErrors.value[key] = $i18n.t(error.value.data[key][0])
}
}
if (error.value.data.non_field_errors) {
errorMsg.value = $i18n.t(error.value.data.non_field_errors[0])
}
} else {
if (error.value.data.detail) {
errorMsg.value = $i18n.t(error.value.data.detail)
} else {
errorMsg.value = 'Something went wrong. Please try again.'
}
}
} else {
setUser(data.value.user)
navigateTo('/account/onboarding?email_verification_required='+data.value.email_verification_required)
}
submitting.value = false
}
}
const handleFieldUpdate = (field) => {
fieldErrors.value[field] = ''
}
</script>
<template>
<v-card
style="height: 100vh"
>
<v-container>
<v-row>
<v-col
sm="9"
offset-sm="1"
md="6"
offset-md="3"
>
<v-card
class="mt-15"
elevation="0"
>
<div class="text-center text-h4">{{$t('Create your account')}}</div>
<v-card-text>
<v-form ref="signUpForm" class="mt-5">
<v-text-field
v-model="formData.username"
:rules="formRules.username"
:error-messages="fieldErrors.username"
:label="$t('username')"
variant="underlined"
@update:modelValue="handleFieldUpdate('username')"
clearable
></v-text-field>
<v-text-field
v-model="formData.email"
:rules="formRules.email"
:error-messages="fieldErrors.email"
:label="$t('email')"
variant="underlined"
@update:modelValue="handleFieldUpdate('email')"
clearable
></v-text-field>
<v-text-field
v-model="formData.password1"
:rules="formRules.password1"
:error-messages="fieldErrors.password1"
:label="$t('password')"
variant="underlined"
@update:modelValue="handleFieldUpdate('password1')"
clearable
></v-text-field>
<v-text-field
v-model="formData.password2"
:rules="formRules.password2"
:error-messages="fieldErrors.password2"
:label="$t('confirmPassword')"
variant="underlined"
@update:modelValue="handleFieldUpdate('password2')"
clearable
></v-text-field>
<v-text-field
v-model="formData.code"
:rules="formRules.code"
:label="$t('invitation code')"
variant="underlined"
@keyup.enter="submit"
clearable
></v-text-field>
</v-form>
<div v-if="errorMsg" class="text-red">{{ errorMsg }}</div>
<div
class="mt-5 d-flex justify-space-between"
>
<v-btn
@click="navigateTo('/account/signin')"
variant="text"
color="primary"
>{{$t('Sign in instead')}}</v-btn>
<v-btn
size="large"
color="primary"
:loading="submitting"
@click="submit"
>{{$t('signUp')}}</v-btn>
</div>
</v-card-text>
</v-card>
</v-col>
</v-row>
</v-container>
</v-card>
</template>

View File

@@ -0,0 +1,100 @@
<script setup>
definePageMeta({
layout: 'vuetify-app',
path: '/account/verify-email/:token',
title: 'Verify Email'
})
const route = useRoute()
const verifying = ref(false)
const status = ref('')
const verifyEmail = async () => {
verifying.value = true
const { data, error } = await useFetch(`/api/account/registration/verify-email/`, {
method: 'POST',
body: JSON.stringify({
key: route.params.token
})
})
if (!error.value && data.value.detail === 'ok') {
status.value = 'success'
} else {
status.value = 'error'
}
verifying.value = false
}
onNuxtReady(() => {
verifyEmail()
})
</script>
<template>
<v-container class="h-100vh">
<v-row
class="fill-height"
align-content="center"
justify="center"
>
<v-col
class="text-subtitle-1 text-center"
cols="12"
v-if="verifying"
>
Verifying your email
</v-col>
<v-col
cols="6"
v-if="verifying"
>
<v-progress-linear
color="deep-purple-accent-4"
indeterminate
rounded
height="6"
></v-progress-linear>
</v-col>
<v-col
cols="12"
v-if="status === 'success'"
class="text-center"
>
<h2 class="text-h4">
Your email has been verified.
</h2>
<p class="text-subtitle-1">
You can now sign in to your account.
</p>
<v-btn
color="primary"
variant="text"
@click="navigateTo('/account/login')"
>
Sign in
</v-btn>
</v-col>
<v-col
cols="12"
v-if="status === 'error'"
class="text-center"
>
<h2 class="text-h4">
There was an error verifying your email.
</h2>
<v-btn
color="primary"
variant="text"
@click="navigateTo('/account/onboarding?resend=1')"
>
Resend email
</v-btn>
</v-col>
</v-row>
</v-container>
</template>
<style scoped>
.h-100vh {
height: 100vh;
}
</style>

View File

@@ -1,159 +1,91 @@
<script setup>
import { fetchEventSource } from '@microsoft/fetch-event-source'
definePageMeta({
middleware: ["auth"],
path: '/:id?',
keepalive: true
})
const { $i18n } = useNuxtApp()
const runtimeConfig = useRuntimeConfig()
const currentModel = useCurrentModel()
const openaiApiKey = useApiKey()
const fetchingResponse = ref(false)
const fetchReply = async (message, parentMessageId) => {
const ctrl = new AbortController()
try {
await fetchEventSource('/api/conversation', {
signal: ctrl.signal,
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
model: currentModel.value,
openaiApiKey: openaiApiKey.value,
message: message,
parentMessageId: parentMessageId,
conversationId: currentConversation.value.id
}),
onopen(response) {
if (response.status === 200) {
return;
}
throw new Error(`Failed to send message. HTTP ${response.status} - ${response.statusText}`);
},
onclose() {
throw new Error(`Failed to send message. Server closed the connection unexpectedly.`);
},
onerror(err) {
throw err;
},
onmessage(message) {
if (message.event === 'error') {
throw new Error(JSON.parse(message.data).error);
}
const { type, data } = JSON.parse(message.data);
if (type === 'done') {
if (currentConversation.value.id === null) {
currentConversation.value.id = data.conversationId
}
currentConversation.value.messages[currentConversation.value.messages.length - 1].id = data.messageId
ctrl.abort();
fetchingResponse.value = false
return;
}
if (currentConversation.value.messages[currentConversation.value.messages.length - 1].from === 'ai') {
currentConversation.value.messages[currentConversation.value.messages.length - 1].message += data
} else {
currentConversation.value.messages.push({id: null, from: 'ai', message: data})
}
scrollChatWindow()
},
})
} catch (err) {
ctrl.abort()
showSnackbar(err.message)
fetchingResponse.value = false
const drawer = useDrawer()
const route = useRoute()
const conversation = ref(getDefaultConversationData())
const loadConversation = async () => {
const { data, error } = await useAuthFetch('/api/chat/conversations/' + route.params.id)
if (!error.value) {
conversation.value = Object.assign(conversation.value, data.value)
}
}
const defaultConversation = ref({
id: null,
messages: []
})
const currentConversation = ref({})
const grab = ref(null)
const scrollChatWindow = () => {
grab.value.scrollIntoView({behavior: 'smooth'})
const loadMessage = async () => {
const { data, error } = await useAuthFetch('/api/chat/messages/?conversationId=' + route.params.id)
if (!error.value) {
conversation.value.messages = data.value
}
}
const createNewConversation = () => {
currentConversation.value = Object.assign(defaultConversation.value, {
if (route.path !== '/') {
return navigateTo('/?new')
}
conversation.value = Object.assign(getDefaultConversationData(), {
topic: $i18n.t('newConversation')
})
}
const send = (message) => {
fetchingResponse.value = true
let parentMessageId = null
if (currentConversation.value.messages.length > 0) {
const lastMessage = currentConversation.value.messages[currentConversation.value.messages.length - 1]
if (lastMessage.from === 'ai' && lastMessage.id !== null) {
parentMessageId = lastMessage.id
}
onMounted(async () => {
if (route.params.id) {
conversation.value.loadingMessages = true
await loadConversation()
await loadMessage()
conversation.value.loadingMessages = false
}
currentConversation.value.messages.push({from: 'me', parentMessageId: parentMessageId, message: message})
fetchReply(message, parentMessageId)
scrollChatWindow()
}
const stop = () => {
ctrl.abort();
fetchingResponse.value = false
}
})
const snackbar = ref(false)
const snackbarText = ref('')
const showSnackbar = (text) => {
snackbarText.value = text
snackbar.value = true
}
createNewConversation()
const navTitle = computed(() => {
if (conversation.value && conversation.value.topic !== null) {
return conversation.value.topic === '' ? $i18n.t('defaultConversationTitle') : conversation.value.topic
}
return runtimeConfig.public.appName
})
onActivated(async () => {
if (route.path === '/' && route.query.new !== undefined) {
createNewConversation()
}
})
</script>
<template>
<div ref="chatWindow">
<v-card
rounded="0"
elevation="0"
v-for="(conversation, index) in currentConversation.messages"
:key="index"
:variant="conversation.from === 'ai' ? 'tonal' : 'text'"
<v-app-bar>
<v-app-bar-nav-icon @click="drawer = !drawer"></v-app-bar-nav-icon>
<v-toolbar-title>{{ navTitle }}</v-toolbar-title>
<v-spacer></v-spacer>
<v-btn
:title="$t('newConversation')"
icon="add"
@click="createNewConversation"
class="d-md-none"
></v-btn>
<v-btn
variant="outlined"
class="text-none d-none d-md-block"
@click="createNewConversation"
>
<v-container>
<v-card-text class="text-caption text-disabled">{{ conversation.from }}</v-card-text>
<v-card-text>
<MsgContent :content="conversation.message" />
</v-card-text>
</v-container>
<v-divider></v-divider>
</v-card>
<div ref="grab" class="w-100" style="height: 150px;"></div>
</div>
<v-footer app class="d-flex flex-column">
<div class="px-md-16 w-100 d-flex align-center">
<v-btn
v-show="fetchingResponse"
icon="close"
title="stop"
class="mr-3"
@click="stop"
></v-btn>
<MsgEditor :send-message="send" :disabled="fetchingResponse" :loading="fetchingResponse" />
</div>
{{ $t('newConversation') }}
</v-btn>
<div class="px-4 py-2 text-disabled text-caption font-weight-light text-center w-100">
{{ new Date().getFullYear() }} {{ runtimeConfig.public.appName }}
</div>
</v-footer>
<v-snackbar
v-model="snackbar"
multi-line
>
{{ snackbarText }}
</v-app-bar>
<template v-slot:actions>
<v-btn
color="red"
variant="text"
@click="snackbar = false"
>
Close
</v-btn>
</template>
</v-snackbar>
<v-main>
<Welcome v-if="!route.params.id && conversation.messages.length === 0" />
<Conversation :conversation="conversation" />
</v-main>
</template>

View File

@@ -1,11 +1,13 @@
import { createVuetify } from 'vuetify'
import { aliases, md } from 'vuetify/iconsets/md'
import * as components from 'vuetify/components'
import { md3 } from 'vuetify/blueprints'
// import * as directives from 'vuetify/directives'
export default defineNuxtPlugin(nuxtApp => {
const vuetify = createVuetify({
ssr: true,
blueprint: md3,
icons: {
defaultSet: 'md',
aliases,

3
public/icon-black.svg Normal file
View File

@@ -0,0 +1,3 @@
<svg width="900" height="900" viewBox="0 0 900 900" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M504.908 750H839.476C850.103 750.001 860.542 747.229 869.745 741.963C878.948 736.696 886.589 729.121 891.9 719.999C897.211 710.876 900.005 700.529 900 689.997C899.995 679.465 897.193 669.12 891.873 660.002L667.187 274.289C661.876 265.169 654.237 257.595 645.036 252.329C635.835 247.064 625.398 244.291 614.773 244.291C604.149 244.291 593.711 247.064 584.511 252.329C575.31 257.595 567.67 265.169 562.36 274.289L504.908 372.979L392.581 179.993C387.266 170.874 379.623 163.301 370.42 158.036C361.216 152.772 350.777 150 340.151 150C329.525 150 319.086 152.772 309.883 158.036C300.679 163.301 293.036 170.874 287.721 179.993L8.12649 660.002C2.80743 669.12 0.00462935 679.465 5.72978e-06 689.997C-0.00461789 700.529 2.78909 710.876 8.10015 719.999C13.4112 729.121 21.0523 736.696 30.255 741.963C39.4576 747.229 49.8973 750.001 60.524 750H270.538C353.748 750 415.112 713.775 457.336 643.101L559.849 467.145L614.757 372.979L779.547 655.834H559.849L504.908 750ZM267.114 655.737L120.551 655.704L340.249 278.586L449.87 467.145L376.474 593.175C348.433 639.03 316.577 655.737 267.114 655.737Z" fill="#0C0C0D"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

BIN
public/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

2
public/robots.txt Normal file
View File

@@ -0,0 +1,2 @@
User-agent: *
Allow: /

View File

@@ -1,108 +0,0 @@
import ChatGPTClient from '@waylaidwanderer/chatgpt-api'
import { PassThrough } from 'node:stream'
const serializeSSEEvent = (chunk) => {
let payload = "";
if (chunk.id) {
payload += `id: ${chunk.id}\n`;
}
if (chunk.event) {
payload += `event: ${chunk.event}\n`;
}
if (chunk.data) {
payload += `data: ${chunk.data}\n`;
}
if (chunk.retry) {
payload += `retry: ${chunk.retry}\n`;
}
if (!payload) {
return "";
}
payload += "\n";
return payload;
}
export default defineEventHandler(async (event) => {
const body = await readBody(event)
const conversationId = body.conversationId ? body.conversationId.toString() : undefined
const parentMessageId = body.parentMessageId ? body.parentMessageId.toString() : undefined
const tunnel = new PassThrough()
const writeToTunnel = (data) => {
tunnel.write(serializeSSEEvent(data))
}
setResponseHeaders(event, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive'
})
if (!body.openaiApiKey) {
writeToTunnel({
event: 'error',
data: JSON.stringify({
code: 503,
error: 'You haven\'t set the api key of openai',
}),
})
return sendStream(event, tunnel)
}
const clientOptions = {
// (Optional) Parameters as described in https://platform.openai.com/docs/api-reference/completions
modelOptions: {
// The model is set to text-chat-davinci-002-20221122 by default, but you can override
// it and any other parameters here
model: body.model,
},
// (Optional) Set custom instructions instead of "You are ChatGPT...".
// promptPrefix: 'You are Bob, a cowboy in Western times...',
// (Optional) Set a custom name for the user
// userLabel: 'User',
// (Optional) Set a custom name for ChatGPT
// chatGptLabel: 'ChatGPT',
// (Optional) Set to true to enable `console.debug()` logging
debug: false,
};
const cacheOptions = {
// Options for the Keyv cache, see https://www.npmjs.com/package/keyv
// This is used for storing conversations, and supports additional drivers (conversations are stored in memory by default)
// For example, to use a JSON file (`npm i keyv-file`) as a database:
// store: new KeyvFile({ filename: 'cache.json' }),
uri: 'sqlite://database.sqlite'
};
const chatGptClient = new ChatGPTClient(body.openaiApiKey, clientOptions, cacheOptions);
try {
const response = await chatGptClient.sendMessage(body.message, {
conversationId,
parentMessageId,
onProgress: (token) => {
// console.log(token)
writeToTunnel({ data: JSON.stringify({
type: 'token',
data: token
})
})
}
});
writeToTunnel({ data: JSON.stringify({
type: 'done',
data: response
}) })
console.log(response)
} catch (e) {
const code = e?.json?.data?.code || 503;
const message = e?.json?.error?.message || 'There was an error communicating with ChatGPT.';
writeToTunnel({
event: 'error',
data: JSON.stringify({
code,
error: message,
}),
})
}
return sendStream(event, tunnel)
})

View File

@@ -1,19 +0,0 @@
import {getSetting, setSetting} from "~/utils/keyv";
import {apiError, apiSuccess} from "~/utils/api";
export default defineEventHandler(async (event) => {
const runtimeConfig = useRuntimeConfig()
const method = getMethod(event)
if (method === 'GET') {
const query = getQuery(event)
let value = await getSetting(query.key)
if (!value && query.key === 'modelName') {
value = runtimeConfig.openaiModelName
}
return apiSuccess(value)
} else if (method === 'POST') {
const body = await readBody(event)
await setSetting(body.key, body.value)
return apiSuccess()
}
})

View File

@@ -0,0 +1,14 @@
import { createProxyMiddleware } from 'http-proxy-middleware'
export default defineEventHandler(async (event) => {
await new Promise((resolve, reject) => {
createProxyMiddleware({
target: process.env.SERVER_DOMAIN,
pathFilter: '/api',
})(event.node.req, event.node.res, (err) => {
if (err)
reject(err)
else
resolve(true)
})
})
})

22
static.Dockerfile Normal file
View File

@@ -0,0 +1,22 @@
FROM node:18-alpine3.16 as builder
WORKDIR /app
COPY package.json yarn.lock ./
RUN yarn install
COPY . .
RUN rm -r server && SSR=false yarn generate
FROM nginx:1.22-alpine
WORKDIR /app
COPY --from=builder /app/.output/public .
COPY nginx.conf /etc/nginx/templates/default.conf.template
EXPOSE 80

View File

@@ -1,16 +0,0 @@
export const apiSuccess = (data) => {
return {
code: 200,
status: 'success',
data: data
}
}
export const apiError = (message) => {
return {
code: 400,
status: 'error',
error: message
}
}

View File

@@ -1,6 +1,29 @@
export const STORAGE_KEY = {
OPENAI_MODELS: 'openai_models',
CURRENT_OPENAI_MODEL: 'current_openai_model',
MODELS: 'models',
CURRENT_MODEL: 'current_model',
OPENAI_API_KEY: 'openai_api_key',
}
}
export const MODELS = {
'gpt-3.5-turbo': {
name: 'gpt-3.5-turbo',
frequency_penalty: 0.0,
presence_penalty: 0.0,
total_tokens: 4096,
max_tokens: 1000,
temperature: 0.7,
top_p: 1.0
},
'gpt-4': {
name: 'gpt-4',
frequency_penalty: 0.0,
presence_penalty: 0.0,
total_tokens: 8192,
max_tokens: 2000,
temperature: 0.7,
top_p: 1.0
}
}
export const DEFAULT_MODEL_NAME = 'gpt-3.5-turbo'

79
utils/helper.js Normal file
View File

@@ -0,0 +1,79 @@
export const getDefaultConversationData = () => {
const { $i18n } = useNuxtApp()
return {
id: null,
topic: null,
messages: [],
loadingMessages: false,
}
}
export const getConversations = async () => {
const { data, error } = await useAuthFetch('/api/chat/conversations/')
if (!error.value) {
return data.value
}
return []
}
export const addConversation = (conversation) => {
const conversations = useConversations()
conversations.value = [conversation, ...conversations.value]
}
export const genTitle = async (conversationId) => {
const { $i18n } = useNuxtApp()
const { data, error } = await useAuthFetch('/api/gen_title/', {
method: 'POST',
body: {
conversationId: conversationId,
prompt: $i18n.t('genTitlePrompt')
}
})
if (!error.value) {
const conversations = useConversations()
let index = conversations.value.findIndex(item => item.id === conversationId)
if (index === -1) {
index = 0
}
conversations.value[index].topic = data.value.title
return data.value.title
}
return null
}
const transformData = (list) => {
const result = {};
for (let i = 0; i < list.length; i++) {
const item = list[i];
result[item.name] = item.value;
}
return result;
}
export const fetchSystemSettings = async () => {
const { data, error } = await useAuthFetch('/api/chat/settings/', {
method: 'GET',
})
if (!error.value) {
const settings = useSettings()
settings.value = transformData(data.value)
}
}
export const fetchUser = async () => {
return useMyFetch('/api/account/user/')
}
export const setUser = (userData) => {
const user = useUser()
user.value = userData
}
export const logout = () => {
const user = useUser()
user.value = null
return navigateTo('/account/signin');
}

View File

@@ -1,18 +0,0 @@
import Keyv from 'keyv'
import KeyvSqlite from "@keyv/sqlite";
const sqlite = new KeyvSqlite()
const cacheOptions = {
namespace: 'settings',
uri: 'sqlite://database.sqlite',
}
const cache = new Keyv(cacheOptions);
export const getSetting = async (key) => {
return await cache.get(key)
}
export const setSetting = async (key, value) => {
return await cache.set(key, value)
}

View File

@@ -1,5 +1,6 @@
const get = (key) => {
if (process.server) return
let val = localStorage.getItem(key)
if (val) {
val = JSON.parse(val)
@@ -8,35 +9,32 @@ const get = (key) => {
}
const set = (key, val) => {
if (process.server) return
localStorage.setItem(key, JSON.stringify(val))
}
const DEFAULT_OPENAI_MODEL = 'text-davinci-003'
export const setModels = (val) => {
const models = useModels()
set(STORAGE_KEY.OPENAI_MODELS, val)
set(STORAGE_KEY.MODELS, val)
models.value = val
}
export const getStoredModels = () => {
let models = get(STORAGE_KEY.OPENAI_MODELS)
if (!models) {
models = [DEFAULT_OPENAI_MODEL]
}
return models
}
// export const getStoredModels = () => {
// let models = get(STORAGE_KEY.MODELS)
// if (!models) {
// models = [DEFAULT_MODEL]
// }
// return models
// }
export const setCurrentModel = (val) => {
const model = useCurrentModel()
set(STORAGE_KEY.CURRENT_OPENAI_MODEL, val)
model.value = val
export const saveCurrentModel = (val) => {
set(STORAGE_KEY.CURRENT_MODEL, val)
}
export const getCurrentModel = () => {
let model = get(STORAGE_KEY.CURRENT_OPENAI_MODEL)
let model = get(STORAGE_KEY.CURRENT_MODEL)
if (!model) {
model = DEFAULT_OPENAI_MODEL
model = MODELS[DEFAULT_MODEL_NAME]
}
return model
}

3757
yarn.lock

File diff suppressed because it is too large Load Diff