Compare commits

...

22 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
20 changed files with 773 additions and 230 deletions

View File

@@ -1,17 +1,25 @@
<p align="center"> <p align="center">
<img alt="demo" src="./demos/demo.gif?v=1"> <img alt="demo" src="./demos/demo.png?v=1">
</p> </p>
# ChatGPT UI # ChatGPT UI
--- A web client for ChatGPT, using OpenAI's API. Provides Docker images and also supports quick deployment to servers using shell scripts.
A web client for ChatGPT, using OpenAI's API.
## 📢Updates ## 📢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). 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). If you still wish to use the old version, please visit the [v1 branch](https://github.com/WongSaang/chatgpt-ui/tree/v1).
@@ -23,10 +31,35 @@ Version 2 introduces the following new features:
- 😀 Ability to store data in an external database (defaulting to Sqlite). - 😀 Ability to store data in an external database (defaulting to Sqlite).
- 😎 Session persistence, allowing the API to answer questions based on your context. - 😎 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.
```bash
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 ## Quick start with Docker Compose
---
### Run services ### Run services
Below is a docker-compose.yml template: Below is a docker-compose.yml template:
@@ -37,48 +70,61 @@ services:
client: client:
image: wongsaang/chatgpt-ui-client:latest image: wongsaang/chatgpt-ui-client:latest
environment: environment:
- SERVER_DOMAIN=http://backend:8000 - SERVER_DOMAIN=http://backend-web-server
depends_on: depends_on:
- backend - backend-web-server
volumes:
- backend_static:/app/static
ports: ports:
- '80:80' - '80:80'
networks: networks:
- chatgpt_ui_network - chatgpt_ui_network
backend: backend-wsgi-server:
image: wongsaang/chatgpt-ui-server:latest image: wongsaang/chatgpt-ui-wsgi-server:latest
environment: environment:
# - 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. - 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_USERNAME=admin # default superuser name
- DJANGO_SUPERUSER_PASSWORD=password # default superuser password - DJANGO_SUPERUSER_PASSWORD=password # default superuser password
- DJANGO_SUPERUSER_EMAIL=admin@example.com # default superuser email - DJANGO_SUPERUSER_EMAIL=admin@example.com # default superuser email
volumes: # If you want to use the email verification function, you need to configure the following parameters
- backend_static:/app/static # - EMAIL_HOST=SMTP server address
# - EMAIL_PORT=SMTP server port
# - EMAIL_HOST_USER=
# - EMAIL_HOST_PASSWORD=
# - EMAIL_USE_TLS=True
ports: ports:
- '8000:8000' - '8000:8000'
networks: networks:
- chatgpt_ui_network - 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: networks:
chatgpt_ui_network: chatgpt_ui_network:
driver: bridge driver: bridge
volumes:
backend_static:
``` ```
### After running ### Set API key
After running the services, you can access the web client at http://localhost, and an admin panel at http://localhost/admin. After running the services, you can access the web client at `http://localhost`, and an admin panel at `http://localhost:9000/admin`.
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. 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 ## Development
---
### Setup ### Setup
Make sure to install the dependencies: Make sure to install the dependencies:

View File

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

View File

@@ -1,18 +1,6 @@
export const useAuthFetch = async (url, options = {}) => { export const useAuthFetch = async (url, options = {}) => {
const { $auth } = useNuxtApp() const { $auth } = useNuxtApp()
const token = await $auth.retrieveToken()
if (!token) {
return await $auth.redirectToLogin()
}
options = Object.assign(options, {
headers: {
'Authorization': 'Bearer ' + token
}
})
const res = await useFetch(url, options) const res = await useFetch(url, options)
if (res.error.value && res.error.value.status === 401) { if (res.error.value && res.error.value.status === 401) {
await $auth.logout() await $auth.logout()

Binary file not shown.

Before

Width:  |  Height:  |  Size: 143 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,34 +0,0 @@
version: '3'
services:
client:
image: wongsaang/chatgpt-ui-client:latest
environment:
- SERVER_DOMAIN=http://backend:8000
depends_on:
- backend
volumes:
- backend_static:/app/static
ports:
- '80:80'
networks:
- chatgpt_ui_network
backend:
image: wongsaang/chatgpt-ui-server:latest
environment:
# - 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
volumes:
- backend_static:/app/static
ports:
- '8000:8000'
networks:
- chatgpt_ui_network
networks:
chatgpt_ui_network:
driver: bridge
volumes:
backend_static:

View File

@@ -1,31 +1,44 @@
version: '3' version: '3'
services: services:
client: client:
build: image: wongsaang/chatgpt-ui-client:latest
context: .
dockerfile: ./Dockerfile
environment: environment:
- SERVER_DOMAIN=http://backend:8000 - SERVER_DOMAIN=http://backend-web-server
depends_on: depends_on:
- backend - backend-web-server
volumes:
- backend_static:/app/static
ports: ports:
- '80:80' - '80:80'
networks: networks:
- chatgpt_ui_network - chatgpt_ui_network
backend: backend-wsgi-server:
image: 'wongsaang/chatgpt-ui-server:latest' image: wongsaang/chatgpt-ui-wsgi-server:latest
volumes: environment:
- backend_static:/app/static - 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: ports:
- '8000:8000' - '8000:8000'
networks: networks:
- chatgpt_ui_network - 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: networks:
chatgpt_ui_network: chatgpt_ui_network:
driver: bridge driver: bridge
volumes:
backend_static:

View File

@@ -16,6 +16,7 @@
"followSystem": "Follow system", "followSystem": "Follow system",
"themeMode": "Theme Mode", "themeMode": "Theme Mode",
"feedback": "Feedback", "feedback": "Feedback",
"clearConversations": "Clear conversations",
"roles": { "roles": {
"me": "Me", "me": "Me",
"ai": "AI" "ai": "AI"

View File

@@ -16,6 +16,7 @@
"followSystem": "跟随系统", "followSystem": "跟随系统",
"themeMode": "主题模式", "themeMode": "主题模式",
"feedback": "反馈", "feedback": "反馈",
"clearConversations": "清除会话",
"roles": { "roles": {
"me": "我", "me": "我",
"ai": "AI" "ai": "AI"

View File

@@ -25,8 +25,62 @@ const setLang = (lang) => {
const conversations = useConversions() const conversations = useConversions()
onNuxtReady(async () => { 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() conversations.value = await getConversions()
loadingConversations.value = false
}
onNuxtReady(async () => {
loadConversations()
}) })
</script> </script>
@@ -39,23 +93,75 @@ onNuxtReady(async () => {
v-model="drawer" v-model="drawer"
> >
<div class="px-2 py-2"> <div class="px-2 py-2">
<v-btn
block
variant="outlined"
prepend-icon="add"
size="large"
@click="createNewConversion()"
>
New conversation
</v-btn>
<v-list> <v-list>
<v-list-item <v-list-item>
v-for="conversation in conversations" <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" :key="conversation.id"
:title="conversation.topic" >
active-color="primary" <v-list-item
@click="openConversationMessages(conversation)" active-color="primary"
></v-list-item> 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> </v-list>
</div> </div>
@@ -64,6 +170,47 @@ onNuxtReady(async () => {
<v-divider></v-divider> <v-divider></v-divider>
<v-list> <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 <v-menu
> >
<template v-slot:activator="{ props }"> <template v-slot:activator="{ props }">

View File

@@ -6,18 +6,16 @@ server {
location / { location / {
root /app; root /app;
index index.html; index index.html;
try_files $uri $uri/ /index.html;
} }
location /api/ location /api/
{ {
proxy_pass ${SERVER_DOMAIN}; proxy_pass ${SERVER_DOMAIN};
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Real-IP $remote_addr;
} proxy_set_header X-Forwarded-Proto $scheme;
location /admin/ {
proxy_pass ${SERVER_DOMAIN};
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
} }
} }

View File

@@ -53,7 +53,8 @@ export default defineNuxtConfig({
devProxy: { devProxy: {
"/api": { "/api": {
target: "http://localhost:8000/api", target: "http://localhost:8000/api",
prependPath: true prependPath: true,
changeOrigin: true,
} }
} }

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>

View File

@@ -1,6 +1,5 @@
<template> <template>
<v-card <v-card
color="red-lighten-5"
style="height: 100vh" style="height: 100vh"
> >
<v-container> <v-container>
@@ -8,13 +7,14 @@
<v-col <v-col
sm="9" sm="9"
offset-sm="1" offset-sm="1"
md="8" md="6"
offset-md="2" offset-md="3"
> >
<v-card <v-card
class="mt-15" class="mt-15"
elevation="0"
> >
<v-card-title>Sign in</v-card-title> <div class="text-center text-h4">Sign in</div>
<v-card-text> <v-card-text>
<v-form ref="signInForm"> <v-form ref="signInForm">
<v-text-field <v-text-field
@@ -28,18 +28,30 @@
:rules="formRules.password" :rules="formRules.password"
label="Password" label="Password"
variant="underlined" variant="underlined"
@keyup.enter="submit"
></v-text-field> ></v-text-field>
<div v-if="errorMsg" class="text-red">{{ errorMsg }}</div>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn
variant="elevated"
color="primary"
:loading="submitting"
@click="submit"
>Submit</v-btn>
</v-card-actions>
</v-form> </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-text>
</v-card> </v-card>
</v-col> </v-col>
@@ -70,17 +82,30 @@ const errorMsg = ref(null)
const signInForm = ref(null) const signInForm = ref(null)
const valid = ref(true) const valid = ref(true)
const submitting = ref(false) const submitting = ref(false)
const route = useRoute()
const submit = async () => { const submit = async () => {
errorMsg.value = null errorMsg.value = null
const { valid } = await signInForm.value.validate() const { valid } = await signInForm.value.validate()
if (valid) { if (valid) {
submitting.value = true submitting.value = true
const error = await $auth.login(formData.value.username, formData.value.password) const { data, error } = await useFetch('/api/account/login/', {
submitting.value = false method: 'POST',
if (!error) { body: JSON.stringify(formData.value)
return await $auth.callback() })
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 || '/')
} }
errorMsg.value = error submitting.value = false
} }
} }

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

@@ -3,6 +3,7 @@ definePageMeta({
middleware: ["auth"] middleware: ["auth"]
}) })
import {EventStreamContentType, fetchEventSource} from '@microsoft/fetch-event-source' import {EventStreamContentType, fetchEventSource} from '@microsoft/fetch-event-source'
import { nextTick } from 'vue'
const { $i18n, $auth } = useNuxtApp() const { $i18n, $auth } = useNuxtApp()
const runtimeConfig = useRuntimeConfig() const runtimeConfig = useRuntimeConfig()
@@ -18,16 +19,14 @@ const abortFetch = () => {
fetchingResponse.value = false fetchingResponse.value = false
} }
const fetchReply = async (message, parentMessageId) => { const fetchReply = async (message, parentMessageId) => {
const token = await $auth.retrieveToken()
ctrl = new AbortController() ctrl = new AbortController()
try { try {
await fetchEventSource('/api/conversation', { await fetchEventSource('/api/conversation/', {
signal: ctrl.signal, signal: ctrl.signal,
method: 'POST', method: 'POST',
headers: { headers: {
'accept': 'application/json', 'accept': 'application/json',
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
}, },
body: JSON.stringify({ body: JSON.stringify({
model: currentModel.value, model: currentModel.value,
@@ -51,7 +50,7 @@ const fetchReply = async (message, parentMessageId) => {
onerror(err) { onerror(err) {
throw err; throw err;
}, },
onmessage(message) { async onmessage(message) {
// console.log(message) // console.log(message)
const event = message.event const event = message.event
const data = JSON.parse(message.data) const data = JSON.parse(message.data)
@@ -121,6 +120,7 @@ const showSnackbar = (text) => {
snackbar.value = true snackbar.value = true
} }
</script> </script>
<template> <template>
@@ -128,21 +128,41 @@ const showSnackbar = (text) => {
v-if="currentConversation.messages.length > 0" v-if="currentConversation.messages.length > 0"
ref="chatWindow" ref="chatWindow"
> >
<v-card <v-container>
rounded="0" <v-row>
elevation="0" <v-col
v-for="(conversation, index) in currentConversation.messages" v-for="(message, index) in currentConversation.messages" :key="index"
:key="index" cols="12"
:variant="conversation.is_bot ? 'tonal' : 'text'" >
> <div
<v-container> class="d-flex"
<v-card-text class="text-caption text-disabled">{{ $t(`roles.${conversation.is_bot?'ai':'me'}`) }}</v-card-text> :class="message.is_bot ? 'justify-start mr-16' : 'justify-end ml-16'"
<v-card-text> >
<MsgContent :content="conversation.message" /> <v-card
</v-card-text> :color="message.is_bot ? '' : 'primary'"
</v-container> rounded="lg"
<v-divider></v-divider> elevation="2"
</v-card> >
<v-card-text>
<MsgContent :content="message.message" />
</v-card-text>
<!-- <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>
</v-col>
</v-row>
</v-container>
<div ref="grab" class="w-100" style="height: 200px;"></div> <div ref="grab" class="w-100" style="height: 200px;"></div>
</div> </div>
<Welcome v-else /> <Welcome v-else />

View File

@@ -1,40 +1,20 @@
const AUTH_ROUTE = { const AUTH_ROUTE = {
home: '/', home: '/',
login: '/login' login: '/account/signin',
}
const COOKIE_OPTIONS = {
prefix: '_Secure-auth',
path: '/',
tokenName: 'access-token',
refreshTokenName: 'refresh-token',
} }
const ENDPOINTS = { const ENDPOINTS = {
login: { login: {
url: '/api/auth/signin' url: '/api/account/login/'
},
refresh: {
url: '/api/auth/token/refresh'
}, },
user: { user: {
url: '/api/auth/session' url: '/api/account/user/'
} }
} }
export default defineNuxtPlugin(() => { export default defineNuxtPlugin(() => {
const tokenKey = COOKIE_OPTIONS.prefix + '.' + COOKIE_OPTIONS.tokenName
const refreshTokenKey = COOKIE_OPTIONS.prefix + '.' + COOKIE_OPTIONS.refreshTokenName
const tokenOptions = {
maxAge: 60 * 5,
}
const refreshTokenOptions = {
maxAge: 60 * 60 * 24,
}
const token = useCookie(tokenKey, tokenOptions)
const refreshToken = useCookie(refreshTokenKey, refreshTokenOptions)
class Auth { class Auth {
constructor() { constructor() {
@@ -42,73 +22,32 @@ export default defineNuxtPlugin(() => {
this.user = useState('user') this.user = useState('user')
} }
async login (username, password) {
const { data, error } = await useFetch(ENDPOINTS.login.url, {
method: 'POST',
body: {
username,
password
}
})
if (!error.value) {
token.value = data.value.access
refreshToken.value = data.value.refresh
return null
}
if (error.value.status === 401) {
return error.value.data.detail
}
return 'Request failed, please try again.'
}
async logout () { async logout () {
this.loginIn.value = false this.loginIn.value = false
this.user.value = null this.user.value = null
await this.redirectToLogin() await this.redirectToLogin()
} }
setUser (user) {
this.user = user
this.loginIn.value = true
}
async fetchUser () { async fetchUser () {
const { data, error } = await useAuthFetch(ENDPOINTS.user.url) const { data, error } = await useFetch(ENDPOINTS.user.url, {
// withCredentials: true
})
if (!error.value) { if (!error.value) {
this.user = data.value this.setUser(data.value)
this.loginIn.value = true
return null return null
} }
return error return error
} }
async refresh () { async redirectToLogin (callback) {
const { data, error } = await useFetch(ENDPOINTS.refresh.url, { return await navigateTo(
method: 'POST', AUTH_ROUTE.login + '?callback=' + encodeURIComponent(callback || AUTH_ROUTE.home)
body: { )
'refresh': refreshToken.value
}
})
if (!error.value) {
token.value = data.value.access
return data.value.access
}
return null
}
async callback () {
return await navigateTo(AUTH_ROUTE.home)
}
async redirectToLogin () {
return await navigateTo(AUTH_ROUTE.login)
}
async retrieveToken () {
const token = useCookie(tokenKey, tokenOptions)
const refreshToken = useCookie(refreshTokenKey, refreshTokenOptions)
if (!refreshToken.value) {
return null
}
if (!token.value) {
return await this.refresh()
}
return token.value
} }
} }
@@ -117,13 +56,9 @@ export default defineNuxtPlugin(() => {
addRouteMiddleware('auth', async (to, from) => { addRouteMiddleware('auth', async (to, from) => {
if (!auth.loginIn.value) { if (!auth.loginIn.value) {
const token = await auth.retrieveToken()
if (!token) {
return await auth.redirectToLogin()
}
const error = await auth.fetchUser() const error = await auth.fetchUser()
if (error) { if (error) {
return await auth.redirectToLogin() return await auth.redirectToLogin(to.fullPath)
} }
} }
}) })

View File

@@ -9,7 +9,7 @@ export const getDefaultConversionData = () => {
} }
export const getConversions = async () => { export const getConversions = async () => {
const { data, error } = await useAuthFetch('/api/chat/conversations') const { data, error } = await useAuthFetch('/api/chat/conversations/')
if (!error.value) { if (!error.value) {
return data.value return data.value
} }
@@ -33,7 +33,7 @@ export const openConversationMessages = async (currentConversation) => {
} }
export const genTitle = async (conversationId) => { export const genTitle = async (conversationId) => {
const { data, error } = await useAuthFetch('/api/gen_title', { const { data, error } = await useAuthFetch('/api/gen_title/', {
method: 'POST', method: 'POST',
body: { body: {
conversationId: conversationId conversationId: conversationId