Compare commits

...

38 Commits

Author SHA1 Message Date
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
33 changed files with 1697 additions and 862 deletions

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

View File

@@ -8,18 +8,15 @@ RUN yarn install
COPY . .
RUN yarn build
RUN yarn generate
FROM node:18-alpine3.16
ENV NITRO_HOST=0.0.0.0
ENV NITRO_PORT=80
FROM nginx:alpine
WORKDIR /app
COPY --from=builder /app/.output .
COPY --from=builder /app/.output/public .
COPY nginx.conf /etc/nginx/templates/default.conf.template
EXPOSE 80
ENTRYPOINT ["node", "server/index.mjs"]

120
README.md
View File

@@ -1,18 +1,128 @@
<p align="center">
<img alt="demo" src="./demos/demo.gif?v=1">
<img alt="demo" src="./demos/demo.png?v=1">
</p>
# ChatGPT UI
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)
A web client for ChatGPT, using OpenAI's API. Provides Docker images and also supports quick deployment to servers using shell scripts.
This project is based on [nuxt3](https://nuxt.com/docs/getting-started/introduction)
## 📢Updates
<details open>
<summary><strong>2023-03-04</strong></summary>
**Update to the latest official chat model** `gpt-3.5-turbo`
**🎉🎉🎉Provide a shell script that can be used to quickly deploy the service to server** [Quick start](#one-click-depolyment)
</details>
<details open>
<summary><strong>2023-02-24</strong></summary>
Version 2 is a major update that separates the backend functionality as an independent project, hosted at [chatgpt-ui-server](https://github.com/WongSaang/chatgpt-ui-server).
If you still wish to use the old version, please visit the [v1 branch](https://github.com/WongSaang/chatgpt-ui/tree/v1).
Version 2 introduces the following new features:
- 😉 Separation of the frontend and backend, with the backend now using the Python-based Django framework.
- 😘 User authentication, supporting multiple users.
- 😀 Ability to store data in an external database (defaulting to Sqlite).
- 😎 Session persistence, allowing the API to answer questions based on your context.
</details>
## 🚀 One-click deployment <a name="one-click-depolyment"></a>
Note: This script has only been tested on Ubuntu Server 22.04 LTS.
## Quick start with docker
```bash
docker run -p 80:80 wongsaang/chatgpt-ui:latest
bash <(curl -Ls https://raw.githubusercontent.com/WongSaang/chatgpt-ui/main/deployment.sh)
```
> If you have a domain name, you can point it to the server's IP address using DNS resolution. Of course, using the server's IP address directly is also possible.
> During the script's execution, you will be prompted to enter a domain name. If you do not have a domain name, you can enter the server's IP address directly.
### After the deployment is complete
Access `http(s)://your.domain:9000/admin` / IP `http(s)://123.123.123.123:9000/admin` to log in to the administration panel.
Default superuser: `admin`
Default password: `password`
Before you can start chatting, you need to add an OpenAI API key. In the Settings model, add a record with the name `openai_api_key` and the value as your API key.
Now you can access the web client at `http(s)://your.domain` or `http://123.123.123.123` to start chatting.
🎉🎉🎉 Enjoy it!
## Quick start with Docker Compose
### Run services
Below is a docker-compose.yml template:
```yaml
version: '3'
services:
client:
image: wongsaang/chatgpt-ui-client:latest
environment:
- SERVER_DOMAIN=http://backend-web-server
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 whitelistAdd the address of your chatgpt-ui-web-server here, default is localhost:9000
#- 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.
#- OPENAI_API_PROXY=https://openai.proxy.com # If you are in China, you can use the proxy provided by the author to speed up the connection to the OpenAI API. If you do not need to use the proxy, you can delete this parameter.
- DJANGO_SUPERUSER_USERNAME=admin # default superuser name
- DJANGO_SUPERUSER_PASSWORD=password # default superuser password
- DJANGO_SUPERUSER_EMAIL=admin@example.com # default superuser email
# 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
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
```
### Set API key
After running the services, you can access the web client at `http://localhost`, and an admin panel at `http://localhost:9000/admin`.
Default superuser: `admin`
Default password: `password`
Before you can start chatting, you need to log in to the admin panel to add an OpenAI API key. In the Settings model, add a record with the name `openai_api_key` and the value as your API key.
## Development
### Setup

100
app.vue
View File

@@ -1,100 +0,0 @@
<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
}
const feedback = () => {
window.open('https://github.com/WongSaang/chatgpt-ui/issues', '_blank')
}
</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
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>
<v-list-item
rounded="xl"
prepend-icon="help_outline"
title="Feedback"
@click="feedback"
></v-list-item>
</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-spacer></v-spacer>
<v-menu
>
<template v-slot:activator="{ props }">
<v-btn
v-bind="props"
icon="help_outline"
title="Feedback"
></v-btn>
</template>
<v-list
>
<v-list-item
@click="feedback"
>
<v-list-item-title>Feedback</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
</v-app-bar>
<v-main>
<NuxtPage/>
</v-main>
</v-app>
</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

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

@@ -20,6 +20,7 @@ const contentHtml = computed(() => {
<template>
<div
v-html="contentHtml"
class="text-body-1 text-justify"
></div>
</template>

View File

@@ -1,13 +1,14 @@
<template>
<v-textarea
v-model="message"
label="Write a message..."
placeholder="Write a message..."
:label="$t('writeAMessage')"
:placeholder="$t('writeAMessage') + '...'"
rows="1"
:auto-grow="autoGrow"
:disabled="disabled"
:loading="loading"
:hint="hint"
:hide-details="loading"
append-inner-icon="send"
@keyup.enter.exact="enterOnly"
@click:appendInner="clickSendBtn"
@@ -32,7 +33,7 @@ export default {
},
computed: {
hint() {
return isMobile() ? "" : "Press Enter to send your message or Shift+Enter to add a new line.";
return isMobile() ? "" : "Press Enter to send your message or Shift+Enter to add a new line";
},
},
watch: {

View File

@@ -3,11 +3,11 @@
<v-row>
<v-col cols="12">
<div class="text-center">
<h2 class="text-h2">Welcome to <span class="text-primary">{{ runtimeConfig.public.appName }}</span></h2>
<h2 class="text-h2">{{ $t('welcomeTo') }} <span class="text-primary">{{ runtimeConfig.public.appName }}</span></h2>
<p class="text-caption mt-5">
{{ runtimeConfig.public.appName }} is an unofficial client for ChatGPT, but uses the official OpenAI API.
{{ runtimeConfig.public.appName }} {{ $t('welcomeScreen.introduction1') }}
<br>
You will need an OpenAI API Key before you can use this client.
{{ $t('welcomeScreen.introduction2') }}
</p>
</div>
</v-col>
@@ -23,7 +23,7 @@
<v-col>
<div class="d-flex flex-column align-center">
<v-icon icon="sunny"></v-icon>
<h3 class="text-h6">Examples</h3>
<h3 class="text-h6">{{ $t('welcomeScreen.examples.title') }}</h3>
</div>
</v-col>
</v-row>
@@ -37,7 +37,7 @@
<v-col>
<div class="d-flex flex-column align-center">
<v-icon icon="bolt"></v-icon>
<h3 class="text-h6">Capabilities</h3>
<h3 class="text-h6">{{ $t('welcomeScreen.capabilities.title') }}</h3>
</div>
</v-col>
</v-row>
@@ -51,7 +51,7 @@
<v-col>
<div class="d-flex flex-column align-center">
<v-icon icon="warning_amber"></v-icon>
<h3 class="text-h6">Limitations</h3>
<h3 class="text-h6">{{ $t('welcomeScreen.limitations.title') }}</h3>
</div>
</v-col>
</v-row>
@@ -65,19 +65,20 @@
<script setup>
const runtimeConfig = useRuntimeConfig()
const { $i18n } = useNuxtApp()
const examples = ref([
'"Explain quantum computing in simple terms"',
'"Got any creative ideas for a 10 year olds birthday?"',
'"How do I make an HTTP request in Javascript?"'
$i18n.t('welcomeScreen.examples.item1'),
$i18n.t('welcomeScreen.examples.item2'),
$i18n.t('welcomeScreen.examples.item3')
])
const capabilities = ref([
'Remembers what user said earlier in the conversation',
'Allows user to provide follow-up corrections',
'Trained to decline inappropriate requests'
$i18n.t('welcomeScreen.capabilities.item1'),
$i18n.t('welcomeScreen.capabilities.item2'),
$i18n.t('welcomeScreen.capabilities.item3')
])
const limitations = ref([
'May occasionally generate incorrect information',
'May occasionally produce harmful instructions or biased content',
'Limited knowledge of world and events after 2021'
$i18n.t('welcomeScreen.limitations.item1'),
$i18n.t('welcomeScreen.limitations.item2'),
$i18n.t('welcomeScreen.limitations.item3')
])
</script>

View File

@@ -0,0 +1,86 @@
<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
dark
color="primary"
>
<v-btn
icon
dark
@click="dialog = false"
>
<v-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>

View File

@@ -1,5 +1,10 @@
export const useModels = () => useState('models', () => getStoredModels())
export const useCurrentModel = () => useState('currentModel', () => getCurrentModel())
export const useApiKey = () => useState('apiKey', () => getStoredApiKey())
export const useConversion = () => useState('conversion', () => getDefaultConversionData())
export const useConversions = () => useState('conversions', () => [])

View File

@@ -0,0 +1,9 @@
export const useAuthFetch = async (url, options = {}) => {
const { $auth } = useNuxtApp()
const res = await useFetch(url, options)
if (res.error.value && res.error.value.status === 401) {
await $auth.logout()
}
return res
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 917 KiB

BIN
demos/demo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 146 KiB

48
deployment.sh Normal file
View File

@@ -0,0 +1,48 @@
#!/bin/bash
read -p "Please enter a resolved domain name: " domain
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 "Starting services..."
sudo APP_DOMAIN="${domain}:9000" docker-compose up -d
echo "Done"

View File

@@ -1,8 +1,44 @@
version: '3'
services:
app:
build:
context: .
dockerfile: ./Dockerfile
client:
image: wongsaang/chatgpt-ui-client:latest
environment:
- SERVER_DOMAIN=http://backend-web-server
depends_on:
- backend-web-server
ports:
- '${APP_PORT:-80}:80'
- '80:80'
networks:
- chatgpt_ui_network
backend-wsgi-server:
image: wongsaang/chatgpt-ui-wsgi-server:latest
environment:
- APP_DOMAIN=${APP_DOMAIN:-localhost:9000}
# - 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
# 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
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

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

@@ -0,0 +1,46 @@
{
"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",
"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",
"clearConversations": "Clear conversations",
"roles": {
"me": "Me",
"ai": "AI"
},
"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"
}
}
}

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

@@ -0,0 +1,46 @@
{
"welcomeTo": "欢迎来到",
"language": "语言",
"setApiKey": "设置API密钥",
"setOpenAIApiKey": "设置OpenAI的API密钥",
"openAIApiKey": "OpenAI的API密钥",
"getAKey": "获取钥匙",
"openAIModels": "OpenAI模型",
"aboutTheModels": "关于模型",
"saveAndClose": "保存并关闭",
"pleaseSelectAtLeastOneModelDot": "请至少选择一个模型",
"writeAMessage": "输入信息",
"pressEnterToSendYourMessageOrShiftEnterToAddANewLine": "按回车键发送您的信息或按Shift+Enter键添加新行",
"lightMode": "明亮模式",
"darkMode": "暗色模式",
"followSystem": "跟随系统",
"themeMode": "主题模式",
"feedback": "反馈",
"clearConversations": "清除会话",
"roles": {
"me": "我",
"ai": "AI"
},
"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年以后的世界和事件了解有限"
}
}
}

296
layouts/default.vue Normal file
View File

@@ -0,0 +1,296 @@
<script setup>
import {useConversions} from "../composables/states";
import {getConversions} from "../utils/helper";
const { $i18n } = useNuxtApp()
const runtimeConfig = useRuntimeConfig()
const colorMode = useColorMode()
const drawer = ref(null)
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 = useConversions()
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) {
conversations.value.splice(index, 1)
}
}
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 getConversions()
loadingConversations.value = false
}
onNuxtReady(async () => {
loadConversations()
})
</script>
<template>
<v-app
:theme="$colorMode.value"
>
<v-navigation-drawer
v-model="drawer"
>
<div class="px-2 py-2">
<v-list>
<v-list-item>
<v-btn
block
variant="outlined"
prepend-icon="add"
@click="createNewConversion()"
class="text-none"
>
New conversation
</v-btn>
</v-list-item>
<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"
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
@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
active-color="primary"
@click="openConversationMessages(conversation)"
v-bind="props"
>
<v-list-item-title>{{ conversation.topic }}</v-list-item-title>
<v-list-item-action v-show="isHovering">
<v-btn
icon="edit"
size="small"
variant="text"
@click="editConversation(cIdx)"
>
</v-btn>
<v-btn
icon="delete"
size="small"
variant="text"
:loading="deletingConversationIndex === cIdx"
@click="deleteConversation(cIdx)"
>
</v-btn>
</v-list-item-action>
</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
width="auto"
>
<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>
<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="$t('themeMode')"
></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>
<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-spacer></v-spacer>
<v-menu
>
<template v-slot:activator="{ props }">
<v-btn
v-bind="props"
icon="help_outline"
title="Feedback"
></v-btn>
</template>
<v-list
>
<v-list-item
@click="feedback"
>
<v-list-item-title>{{ $t('feedback') }}</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
</v-app-bar>
<v-main>
<NuxtPage/>
</v-main>
</v-app>
</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>

7
layouts/vuetifyApp.vue Normal file
View File

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

21
nginx.conf Normal file
View File

@@ -0,0 +1,21 @@
server {
listen 80;
listen [::]:80;
server_name localhost;
location / {
root /app;
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

@@ -22,5 +22,41 @@ export default defineNuxtConfig({
'material-design-icons-iconfont/dist/material-design-icons.css',
'highlight.js/styles/panda-syntax-dark.css',
],
modules: ['@nuxtjs/color-mode']
modules: [
'@nuxtjs/color-mode',
'@nuxtjs/i18n'
],
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',
}
],
lazy: true,
langDir: 'lang',
defaultLocale: 'en',
vueI18n: {
fallbackLocale: 'en',
},
},
nitro: {
devProxy: {
"/api": {
target: "http://localhost:8000/api",
prependPath: true,
changeOrigin: true,
}
}
},
})

View File

@@ -9,15 +9,16 @@
},
"devDependencies": {
"@nuxtjs/color-mode": "^3.2.0",
"@nuxtjs/i18n": "^8.0.0-beta.9",
"material-design-icons-iconfont": "^6.7.0",
"nuxt": "^3.2.0"
},
"dependencies": {
"@microsoft/fetch-event-source": "^2.0.1",
"@waylaidwanderer/chatgpt-api": "^1.12.2",
"highlight.js": "^11.7.0",
"is-mobile": "^3.1.1",
"marked": "^4.2.12",
"nanoid": "^4.0.1",
"vuetify": "^3.0.6"
},
"license": "MIT"

View File

@@ -0,0 +1,81 @@
<script setup>
definePageMeta({
layout: 'vuetify-app',
middleware: ['auth']
})
const route = useRoute()
const sending = ref(false)
const resent = ref(false)
const errorMsg = ref(null)
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">
<h2 class="text-h4">Verify your email</h2>
<p class="text-body-2 mt-5">
We've sent a verification email to <strong>{{ $auth.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>
</v-card>
</v-col>
</v-row>
</v-container>
</v-card>
</template>
<style scoped>
.h-100vh {
height: 100vh;
}
.mt-20vh {
margin-top: 20vh;
}
</style>

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

@@ -0,0 +1,112 @@
<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">Sign in</div>
<v-card-text>
<v-form ref="signInForm">
<v-text-field
v-model="formData.username"
:rules="formRules.username"
label="User name"
variant="underlined"
></v-text-field>
<v-text-field
v-model="formData.password"
:rules="formRules.password"
label="Password"
variant="underlined"
@keyup.enter="submit"
></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"
>Create account</v-btn>
<v-btn
color="primary"
:loading="submitting"
@click="submit"
size="large"
>Submit</v-btn>
</div>
</v-card-text>
</v-card>
</v-col>
</v-row>
</v-container>
</v-card>
</template>
<script setup>
definePageMeta({
layout: 'vuetify-app'
})
const formData = ref({
username: '',
password: ''
})
const formRules = ref({
username: [
v => !!v || 'Username is required'
],
password: [
v => !!v || 'Password is required'
]
})
const { $auth } = useNuxtApp()
const errorMsg = ref(null)
const signInForm = ref(null)
const valid = ref(true)
const submitting = ref(false)
const route = useRoute()
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)
})
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 {
$auth.setUser(data.value.user)
navigateTo(route.query.callback || '/')
}
submitting.value = false
}
}
</script>

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

@@ -0,0 +1,172 @@
<script setup>
definePageMeta({
layout: 'vuetify-app'
})
const { $auth } = useNuxtApp()
const formData = ref({
username: '',
email: '',
password1: '',
password2: '',
})
const fieldErrors = ref({
username: '',
email: '',
password1: '',
password2: '',
})
const formRules = ref({
username: [
v => !!v || 'Please enter your username',
v => v.length >= 4 || 'Username must be at least 4 characters'
],
email: [
v => !!v || 'Please enter your e-mail address',
v => /.+@.+\..+/.test(v) || 'E-mail address must be valid'
],
password1: [
v => !!v || 'Please enter your password',
v => v.length >= 8 || 'Password must be at least 8 characters'
],
password2: [
v => !!v || 'Please confirm your password',
v => v.length >= 8 || 'Password must be at least 8 characters',
v => v === formData.value.password1 || 'Confirm password must match password'
]
})
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] = error.value.data[key][0]
}
}
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 {
$auth.setUser(data.value.user)
navigateTo('/account/onboarding')
}
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">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="User name"
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="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="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="Confirm password"
variant="underlined"
@update:modelValue="handleFieldUpdate('password2')"
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"
>Sign in instead</v-btn>
<v-btn
size="large"
color="primary"
:loading="submitting"
@click="submit"
>Submit</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,18 +1,32 @@
<script setup>
import { fetchEventSource } from '@microsoft/fetch-event-source'
definePageMeta({
middleware: ["auth"]
})
import {EventStreamContentType, fetchEventSource} from '@microsoft/fetch-event-source'
import { nextTick } from 'vue'
const { $i18n, $auth } = useNuxtApp()
const runtimeConfig = useRuntimeConfig()
const currentModel = useCurrentModel()
const openaiApiKey = useApiKey()
const fetchingResponse = ref(false)
let ctrl
const abortFetch = () => {
if (ctrl) {
ctrl.abort()
}
fetchingResponse.value = false
}
const fetchReply = async (message, parentMessageId) => {
const ctrl = new AbortController()
ctrl = new AbortController()
try {
await fetchEventSource('/api/conversation', {
await fetchEventSource('/api/conversation/', {
signal: ctrl.signal,
method: 'POST',
headers: {
'Content-Type': 'application/json'
'accept': 'application/json',
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: currentModel.value,
@@ -22,77 +36,81 @@ const fetchReply = async (message, parentMessageId) => {
conversationId: currentConversation.value.id
}),
onopen(response) {
if (response.status === 200) {
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;
},
onmessage(message) {
if (message.event === 'error') {
throw new Error(JSON.parse(message.data).error);
async onmessage(message) {
// console.log(message)
const event = message.event
const data = JSON.parse(message.data)
if (event === 'error') {
throw new Error(data.error);
}
const { type, data } = JSON.parse(message.data);
if (type === 'done') {
if (event === 'done') {
if (currentConversation.value.id === null) {
currentConversation.value.id = data.conversationId
genTitle(currentConversation.value.id)
}
currentConversation.value.messages[currentConversation.value.messages.length - 1].id = data.messageId
ctrl.abort();
fetchingResponse.value = false
abortFetch()
return;
}
if (currentConversation.value.messages[currentConversation.value.messages.length - 1].from === 'ai') {
currentConversation.value.messages[currentConversation.value.messages.length - 1].message += data
if (currentConversation.value.messages[currentConversation.value.messages.length - 1].is_bot) {
currentConversation.value.messages[currentConversation.value.messages.length - 1].message += data.content
} else {
currentConversation.value.messages.push({id: null, from: 'ai', message: data})
currentConversation.value.messages.push({id: null, is_bot: true, message: data.content})
}
scrollChatWindow()
},
})
} catch (err) {
ctrl.abort()
console.log(err)
abortFetch()
showSnackbar(err.message)
fetchingResponse.value = false
}
}
const defaultConversation = ref({
id: null,
messages: []
})
const currentConversation = ref({})
const currentConversation = useConversion()
const grab = ref(null)
const scrollChatWindow = () => {
if (grab.value === null) {
return;
}
grab.value.scrollIntoView({behavior: 'smooth'})
}
const createNewConversation = () => {
currentConversation.value = Object.assign(defaultConversation.value, {
})
}
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) {
if (lastMessage.is_bot && lastMessage.id !== null) {
parentMessageId = lastMessage.id
}
}
currentConversation.value.messages.push({from: 'me', parentMessageId: parentMessageId, message: message})
currentConversation.value.messages.push({parentMessageId: parentMessageId, message: message})
fetchReply(message, parentMessageId)
scrollChatWindow()
}
const stop = () => {
ctrl.abort();
fetchingResponse.value = false
abortFetch()
}
const snackbar = ref(false)
@@ -102,30 +120,50 @@ const showSnackbar = (text) => {
snackbar.value = true
}
createNewConversation()
</script>
<template>
<div
v-if="currentConversation.messages.length > 0"
ref="chatWindow"
>
<v-card
rounded="0"
elevation="0"
v-for="(conversation, index) in currentConversation.messages"
:key="index"
:variant="conversation.from === 'ai' ? 'tonal' : 'text'"
>
<v-container>
<v-card-text class="text-caption text-disabled">{{ conversation.from }}</v-card-text>
<v-row>
<v-col
v-for="(message, index) in currentConversation.messages" :key="index"
cols="12"
>
<div
class="d-flex"
:class="message.is_bot ? 'justify-start mr-16' : 'justify-end ml-16'"
>
<v-card
:color="message.is_bot ? '' : 'primary'"
rounded="lg"
elevation="2"
>
<v-card-text>
<MsgContent :content="conversation.message" />
<MsgContent :content="message.message" />
</v-card-text>
</v-container>
<v-divider></v-divider>
<!-- <v-card-actions-->
<!-- v-if="message.is_bot"-->
<!-- >-->
<!-- <v-spacer></v-spacer>-->
<!-- <v-tooltip text="Copy">-->
<!-- <template v-slot:activator="{ props }">-->
<!-- <v-btn v-bind="props" icon="content_copy"></v-btn>-->
<!-- </template>-->
<!-- </v-tooltip>-->
<!-- </v-card-actions>-->
</v-card>
<div ref="grab" class="w-100" style="height: 150px;"></div>
</div>
</v-col>
</v-row>
</v-container>
<div ref="grab" class="w-100" style="height: 200px;"></div>
</div>
<Welcome v-else />
<v-footer app class="d-flex flex-column">
@@ -141,7 +179,7 @@ createNewConversation()
</div>
<div class="px-4 py-2 text-disabled text-caption font-weight-light text-center w-100">
© {{ new Date().getFullYear() }} {{ runtimeConfig.public.appName }}
© {{ new Date().getFullYear() }} {{ runtimeConfig.public.appName }}
</div>
</v-footer>
<v-snackbar

71
plugins/auth.js Normal file
View File

@@ -0,0 +1,71 @@
const AUTH_ROUTE = {
home: '/',
login: '/account/signin',
}
const ENDPOINTS = {
login: {
url: '/api/account/login/'
},
user: {
url: '/api/account/user/'
}
}
export default defineNuxtPlugin(() => {
class Auth {
constructor() {
this.loginIn = useState('loginIn', () => false)
this.user = useState('user')
}
async logout () {
this.loginIn.value = false
this.user.value = null
await this.redirectToLogin()
}
setUser (user) {
this.user = user
this.loginIn.value = true
}
async fetchUser () {
const { data, error } = await useFetch(ENDPOINTS.user.url, {
// withCredentials: true
})
if (!error.value) {
this.setUser(data.value)
return null
}
return error
}
async redirectToLogin (callback) {
return await navigateTo(
AUTH_ROUTE.login + '?callback=' + encodeURIComponent(callback || AUTH_ROUTE.home)
)
}
}
const auth = new Auth()
addRouteMiddleware('auth', async (to, from) => {
if (!auth.loginIn.value) {
const error = await auth.fetchUser()
if (error) {
return await auth.redirectToLogin(to.fullPath)
}
}
})
return {
provide: {
auth
}
}
})

View File

@@ -1,107 +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' }),
};
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,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
}
}

53
utils/helper.js Normal file
View File

@@ -0,0 +1,53 @@
export const getDefaultConversionData = () => {
return {
id: null,
topic: null,
messages: [],
loadingMessages: false,
}
}
export const getConversions = async () => {
const { data, error } = await useAuthFetch('/api/chat/conversations/')
if (!error.value) {
return data.value
}
return []
}
export const createNewConversion = () => {
const conversation = useConversion()
conversation.value = getDefaultConversionData()
}
export const openConversationMessages = async (currentConversation) => {
const conversation = useConversion()
conversation.value = Object.assign(conversation.value, currentConversation)
conversation.value.loadingMessages = true
const { data, error } = await useAuthFetch('/api/chat/messages/?conversationId=' + currentConversation.id)
if (!error.value) {
conversation.value.messages = data.value
}
conversation.value.loadingMessages = true
}
export const genTitle = async (conversationId) => {
const { data, error } = await useAuthFetch('/api/gen_title/', {
method: 'POST',
body: {
conversationId: conversationId
}
})
if (!error.value) {
const conversation = {
id: conversationId,
topic: data.value.title,
}
const conversations = useConversions()
// prepend to conversations
conversations.value = [conversation, ...conversations.value]
return data.value.title
}
return null
}

771
yarn.lock

File diff suppressed because it is too large Load Diff