Compare commits

..

15 Commits

Author SHA1 Message Date
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
13 changed files with 480 additions and 195 deletions

View File

@@ -4,14 +4,19 @@
# ChatGPT UI
---
A web client for ChatGPT, using OpenAI's API.
## 📢Updates
---
<details open>
<summary><strong>2023-03-04</strong></summary>
**Update to the latest official chat model ** `gpt-3.5-turbo`
</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).
@@ -23,10 +28,11 @@ Version 2 introduces the following new features:
- 😀 Ability to store data in an external database (defaulting to Sqlite).
- 😎 Session persistence, allowing the API to answer questions based on your context.
</details>
## Quick start with Docker Compose
---
### Run services
Below is a docker-compose.yml template:
@@ -37,48 +43,53 @@ services:
client:
image: wongsaang/chatgpt-ui-client:latest
environment:
- SERVER_DOMAIN=http://backend:8000
- SERVER_DOMAIN=http://backend-web-server
depends_on:
- backend
volumes:
- backend_static:/app/static
- backend-web-server
ports:
- '80:80'
networks:
- chatgpt_ui_network
backend:
image: wongsaang/chatgpt-ui-server:latest
backend-wsgi-server:
image: wongsaang/chatgpt-ui-wsgi-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
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
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
---
### Setup
Make sure to install the dependencies:

View File

@@ -1,18 +1,6 @@
export const useAuthFetch = async (url, options = {}) => {
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)
if (res.error.value && res.error.value.status === 401) {
await $auth.logout()

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

View File

@@ -12,12 +12,8 @@ server {
{
proxy_pass ${SERVER_DOMAIN};
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
location /admin/ {
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

@@ -53,7 +53,8 @@ export default defineNuxtConfig({
devProxy: {
"/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>
<v-card
color="red-lighten-5"
style="height: 100vh"
>
<v-container>
@@ -8,13 +7,14 @@
<v-col
sm="9"
offset-sm="1"
md="8"
offset-md="2"
md="6"
offset-md="3"
>
<v-card
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-form ref="signInForm">
<v-text-field
@@ -28,18 +28,30 @@
:rules="formRules.password"
label="Password"
variant="underlined"
@keyup.enter="submit"
></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>
<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>
@@ -70,17 +82,30 @@ 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 error = await $auth.login(formData.value.username, formData.value.password)
submitting.value = false
if (!error) {
return await $auth.callback()
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 || '/')
}
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

@@ -18,16 +18,14 @@ const abortFetch = () => {
fetchingResponse.value = false
}
const fetchReply = async (message, parentMessageId) => {
const token = await $auth.retrieveToken()
ctrl = new AbortController()
try {
await fetchEventSource('/api/conversation', {
await fetchEventSource('/api/conversation/', {
signal: ctrl.signal,
method: 'POST',
headers: {
'accept': 'application/json',
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({
model: currentModel.value,

View File

@@ -1,40 +1,20 @@
const AUTH_ROUTE = {
home: '/',
login: '/login'
}
const COOKIE_OPTIONS = {
prefix: '_Secure-auth',
path: '/',
tokenName: 'access-token',
refreshTokenName: 'refresh-token',
login: '/account/signin',
}
const ENDPOINTS = {
login: {
url: '/api/auth/signin'
},
refresh: {
url: '/api/auth/token/refresh'
url: '/api/account/login/'
},
user: {
url: '/api/auth/session'
url: '/api/account/user/'
}
}
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 {
constructor() {
@@ -42,73 +22,32 @@ export default defineNuxtPlugin(() => {
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 () {
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 useAuthFetch(ENDPOINTS.user.url)
const { data, error } = await useFetch(ENDPOINTS.user.url, {
// withCredentials: true
})
if (!error.value) {
this.user = data.value
this.loginIn.value = true
this.setUser(data.value)
return null
}
return error
}
async refresh () {
const { data, error } = await useFetch(ENDPOINTS.refresh.url, {
method: 'POST',
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
async redirectToLogin (callback) {
return await navigateTo(
AUTH_ROUTE.login + '?callback=' + encodeURIComponent(callback || AUTH_ROUTE.home)
)
}
}
@@ -117,13 +56,9 @@ export default defineNuxtPlugin(() => {
addRouteMiddleware('auth', async (to, from) => {
if (!auth.loginIn.value) {
const token = await auth.retrieveToken()
if (!token) {
return await auth.redirectToLogin()
}
const error = await auth.fetchUser()
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 () => {
const { data, error } = await useAuthFetch('/api/chat/conversations')
const { data, error } = await useAuthFetch('/api/chat/conversations/')
if (!error.value) {
return data.value
}
@@ -33,7 +33,7 @@ export const openConversationMessages = async (currentConversation) => {
}
export const genTitle = async (conversationId) => {
const { data, error } = await useAuthFetch('/api/gen_title', {
const { data, error } = await useAuthFetch('/api/gen_title/', {
method: 'POST',
body: {
conversationId: conversationId