Compare commits
78 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
18a4251714 | ||
|
|
878fda0054 | ||
|
|
1f3a025918 | ||
|
|
f9db3e5866 | ||
|
|
c9615ed05c | ||
|
|
0d4b6247e2 | ||
|
|
c9c3431cff | ||
|
|
46abf3daa0 | ||
|
|
8dcd7f46b1 | ||
|
|
33d9c392fa | ||
|
|
bb17cdd123 | ||
|
|
4cfc9f4aea | ||
|
|
cd50086c1e | ||
|
|
7e5498f779 | ||
|
|
d933236a5d | ||
|
|
0be2d45cd5 | ||
|
|
e24ad26d99 | ||
|
|
052f5299a0 | ||
|
|
8340edbf40 | ||
|
|
7bff84638e | ||
|
|
54660706e3 | ||
|
|
a8acfeea58 | ||
|
|
85fc57e2b2 | ||
|
|
fe4740b7a2 | ||
|
|
2210dfcb98 | ||
|
|
19794016fd | ||
|
|
ce348c0f38 | ||
|
|
f251b16afe | ||
|
|
4f32ef69b2 | ||
|
|
e354a9490f | ||
|
|
3d2c041cc2 | ||
|
|
17588443e6 | ||
|
|
298d7c1bda | ||
|
|
8e27487cbb | ||
|
|
a91f1b1348 | ||
|
|
63b95c2ce2 | ||
|
|
03512e8c7e | ||
|
|
002db29717 | ||
|
|
6402f156dd | ||
|
|
a44ec5e2fb | ||
|
|
32f3013337 | ||
|
|
e66d994219 | ||
|
|
f166581a73 | ||
|
|
ef6657187a | ||
|
|
3b6c48a776 | ||
|
|
b316ac0b4a | ||
|
|
51e8ea8d1a | ||
|
|
60cd0689fb | ||
|
|
74fc850ceb | ||
|
|
339dd1e0c6 | ||
|
|
122704737a | ||
|
|
bd35c21e2f | ||
|
|
c2705e5f2a | ||
|
|
0e5aeddffa | ||
|
|
d9b1ece762 | ||
|
|
000e9f170f | ||
|
|
d96b5ad26a | ||
|
|
03d7dc2589 | ||
|
|
8685c8e87f | ||
|
|
49d634987d | ||
|
|
3e46512c15 | ||
|
|
eb7f062144 | ||
|
|
3c7d45154e | ||
|
|
13798e668a | ||
|
|
d431048dc4 | ||
|
|
9215965d45 | ||
|
|
66767d9352 | ||
|
|
5abd5edba5 | ||
|
|
233eb9c27a | ||
|
|
5201349363 | ||
|
|
cdd8a86de0 | ||
|
|
96902c9e14 | ||
|
|
b10fafd6a8 | ||
|
|
58e92bfe84 | ||
|
|
efd1c96852 | ||
|
|
1ee3469978 | ||
|
|
65629ca5a6 | ||
|
|
f64a45c0ee |
2
.github/workflows/docker-image.yml
vendored
2
.github/workflows/docker-image.yml
vendored
@@ -32,4 +32,4 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
push: true
|
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 }}
|
||||||
|
|||||||
13
Dockerfile
13
Dockerfile
@@ -8,18 +8,15 @@ RUN yarn install
|
|||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
RUN yarn build
|
RUN yarn generate
|
||||||
|
|
||||||
|
|
||||||
FROM node:18-alpine3.16
|
FROM nginx:alpine
|
||||||
|
|
||||||
ENV NITRO_HOST=0.0.0.0
|
|
||||||
ENV NITRO_PORT=80
|
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY --from=builder /app/.output .
|
COPY --from=builder /app/.output/public .
|
||||||
|
|
||||||
|
COPY nginx.conf /etc/nginx/templates/default.conf.template
|
||||||
|
|
||||||
EXPOSE 80
|
EXPOSE 80
|
||||||
|
|
||||||
ENTRYPOINT ["node", "server/index.mjs"]
|
|
||||||
150
README.md
150
README.md
@@ -2,17 +2,159 @@
|
|||||||
<img alt="demo" src="./demos/demo.gif?v=1">
|
<img alt="demo" src="./demos/demo.gif?v=1">
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
[English](./README.md) | [中文](./docs/zh/README.md)
|
||||||
|
|
||||||
# ChatGPT UI
|
# 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 ChatGPT web client that supports multiple users, multiple database connections for persistent data storage, supports i18n. Provides Docker images and quick deployment scripts.
|
||||||
|
|
||||||
This project is based on [nuxt3](https://nuxt.com/docs/getting-started/introduction)
|
## 📢Updates
|
||||||
|
<details open>
|
||||||
|
<summary><strong>2023-03-15</strong></summary>
|
||||||
|
|
||||||
|
Add "open_registration" setting option in the admin panel to control whether user registration is enabled. You can log in to the admin panel and find this setting option under `Chat->Setting`. The default value of this setting is `True` (allow user registration). If you do not need it, please change it to `False`.
|
||||||
|
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details open>
|
||||||
|
<summary><strong>2023-03-10</strong></summary>
|
||||||
|
|
||||||
|
Add 2 environment variables to control the typewriter effect:
|
||||||
|
|
||||||
|
- `NUXT_PUBLIC_TYPEWRITER=true` to enable/disable the typewriter effect
|
||||||
|
- `NUXT_PUBLIC_TYPEWRITER_DELAY=50` to set the delay time for each character in milliseconds.
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<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).
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
|
## 🚀 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
|
```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
|
||||||
|
- NUXT_PUBLIC_APP_NAME='ChatGPT UI' # App name
|
||||||
|
- NUXT_PUBLIC_TYPEWRITER=true # Enable typewriter effect, default is false
|
||||||
|
- NUXT_PUBLIC_TYPEWRITER_DELAY=100 # Typewriter effect delay time, default is 50ms
|
||||||
|
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 whitelist,Add 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/v1 # Proxy for https://api.openai.com/v1
|
||||||
|
- 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
|
||||||
|
```
|
||||||
|
|
||||||
|
### DB_URL schema
|
||||||
|
|
||||||
|
| Engine | URL |
|
||||||
|
|----------------------|--------------------------------------------------|
|
||||||
|
| PostgreSQL | ``postgres://USER:PASSWORD@HOST:PORT/NAME`` |
|
||||||
|
| MySQL | ``mysql://USER:PASSWORD@HOST:PORT/NAME`` |
|
||||||
|
| SQLite | ``sqlite:///PATH`` |
|
||||||
|
|
||||||
|
|
||||||
|
### Set API key
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
### Setup
|
### Setup
|
||||||
|
|||||||
72
app.vue
72
app.vue
@@ -1,67 +1,9 @@
|
|||||||
<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
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<v-app
|
<div>
|
||||||
:theme="$colorMode.value"
|
<VitePwaManifest />
|
||||||
>
|
<NuxtLoadingIndicator />
|
||||||
<v-navigation-drawer
|
<NuxtLayout>
|
||||||
v-model="drawer"
|
<NuxtPage />
|
||||||
>
|
</NuxtLayout>
|
||||||
<v-list>
|
</div>
|
||||||
<ModelDialog/>
|
|
||||||
</v-list>
|
|
||||||
|
|
||||||
<template v-slot:append>
|
|
||||||
<v-divider></v-divider>
|
|
||||||
<v-list>
|
|
||||||
<ApiKeyDialog/>
|
|
||||||
|
|
||||||
<v-menu
|
|
||||||
>
|
|
||||||
<template v-slot:activator="{ props }">
|
|
||||||
<v-list-item
|
|
||||||
v-bind="props"
|
|
||||||
rounded="xl"
|
|
||||||
:prepend-icon="$colorMode.value === 'light' ? 'light_mode' : 'dark_mode'"
|
|
||||||
title="Theme mode"
|
|
||||||
></v-list-item>
|
|
||||||
</template>
|
|
||||||
<v-list>
|
|
||||||
<v-list-item
|
|
||||||
v-for="(theme, idx) in themes"
|
|
||||||
:key="idx"
|
|
||||||
@click="setTheme(theme.value)"
|
|
||||||
>
|
|
||||||
<v-list-item-title>{{ theme.title }}</v-list-item-title>
|
|
||||||
</v-list-item>
|
|
||||||
</v-list>
|
|
||||||
</v-menu>
|
|
||||||
</v-list>
|
|
||||||
</template>
|
|
||||||
</v-navigation-drawer>
|
|
||||||
|
|
||||||
<v-app-bar
|
|
||||||
class="d-lg-none"
|
|
||||||
>
|
|
||||||
<v-app-bar-nav-icon @click="drawer = !drawer"></v-app-bar-nav-icon>
|
|
||||||
|
|
||||||
<v-toolbar-title>{{ runtimeConfig.public.appName }}</v-toolbar-title>
|
|
||||||
</v-app-bar>
|
|
||||||
|
|
||||||
<v-main>
|
|
||||||
<NuxtPage/>
|
|
||||||
</v-main>
|
|
||||||
</v-app>
|
|
||||||
</template>
|
</template>
|
||||||
@@ -10,17 +10,17 @@
|
|||||||
prepend-icon="vpn_key"
|
prepend-icon="vpn_key"
|
||||||
color="primary"
|
color="primary"
|
||||||
>
|
>
|
||||||
Set OpenAI Api Key
|
{{ $t('setApiKey') }}
|
||||||
</v-list-item>
|
</v-list-item>
|
||||||
</template>
|
</template>
|
||||||
<v-card>
|
<v-card>
|
||||||
<v-card-title>
|
<v-card-title>
|
||||||
<span class="text-h5">OpenAI Api Key</span>
|
<span class="text-h5">{{ $t('openAIApiKey') }}</span>
|
||||||
</v-card-title>
|
</v-card-title>
|
||||||
<v-divider></v-divider>
|
<v-divider></v-divider>
|
||||||
<v-card-text>
|
<v-card-text>
|
||||||
<div>
|
<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>
|
<a target="_blank" href="https://platform.openai.com/account/api-keys">https://platform.openai.com/account/api-keys</a>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
|
|||||||
78
components/MessageActions.vue
Normal file
78
components/MessageActions.vue
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
<script setup>
|
||||||
|
import copy from 'copy-to-clipboard'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
message: {
|
||||||
|
type: Object,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
messageIndex: {
|
||||||
|
type: Number,
|
||||||
|
required: true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const snackbar = ref(false)
|
||||||
|
const snackbarText = ref('')
|
||||||
|
const showSnackbar = (text) => {
|
||||||
|
snackbarText.value = text
|
||||||
|
snackbar.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const copyMessage = () => {
|
||||||
|
copy(props.message.message)
|
||||||
|
showSnackbar('Copied!')
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteMessage = async () => {
|
||||||
|
const { data, error } = await useAuthFetch(`/api/chat/messages/${props.message.id}/`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
})
|
||||||
|
if (!error.value) {
|
||||||
|
this.$emit('deleteMessage', props.messageIndex)
|
||||||
|
showSnackbar('Deleted!')
|
||||||
|
}
|
||||||
|
showSnackbar('Delete failed')
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<v-menu
|
||||||
|
>
|
||||||
|
<template v-slot:activator="{ props }">
|
||||||
|
<v-btn
|
||||||
|
v-bind="props"
|
||||||
|
icon
|
||||||
|
variant="text"
|
||||||
|
class="mx-1"
|
||||||
|
>
|
||||||
|
<v-icon icon="more_horiz"></v-icon>
|
||||||
|
</v-btn>
|
||||||
|
</template>
|
||||||
|
<v-list>
|
||||||
|
<v-list-item
|
||||||
|
@click="copyMessage()"
|
||||||
|
>
|
||||||
|
<v-list-item-title>{{ $t('copy') }}</v-list-item-title>
|
||||||
|
</v-list-item>
|
||||||
|
<!-- <v-list-item-->
|
||||||
|
<!-- @click="deleteMessage()"-->
|
||||||
|
<!-- >-->
|
||||||
|
<!-- <v-list-item-title>{{ $t('delete') }}</v-list-item-title>-->
|
||||||
|
<!-- </v-list-item>-->
|
||||||
|
</v-list>
|
||||||
|
</v-menu>
|
||||||
|
|
||||||
|
<v-snackbar
|
||||||
|
v-model="snackbar"
|
||||||
|
location="top"
|
||||||
|
timeout="2000"
|
||||||
|
>
|
||||||
|
{{ snackbarText }}
|
||||||
|
</v-snackbar>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
|
||||||
|
</style>
|
||||||
@@ -17,12 +17,12 @@
|
|||||||
</template>
|
</template>
|
||||||
<v-card>
|
<v-card>
|
||||||
<v-card-title>
|
<v-card-title>
|
||||||
<span class="text-h5">OpenAI Models</span>
|
<span class="text-h5">{{ $t('openAIModels') }}</span>
|
||||||
</v-card-title>
|
</v-card-title>
|
||||||
<v-divider></v-divider>
|
<v-divider></v-divider>
|
||||||
<v-card-text>
|
<v-card-text>
|
||||||
<div>
|
<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>
|
<a target="_blank" href="https://platform.openai.com/docs/models/overview">https://platform.openai.com/docs/models/overview</a>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
@@ -77,7 +77,7 @@
|
|||||||
color="primary"
|
color="primary"
|
||||||
@click="save"
|
@click="save"
|
||||||
>
|
>
|
||||||
Save & Close
|
{{ $t('saveAndClose') }}
|
||||||
</v-btn>
|
</v-btn>
|
||||||
</v-card-actions>
|
</v-card-actions>
|
||||||
</v-card>
|
</v-card>
|
||||||
@@ -85,6 +85,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
|
const { $i18n } = useNuxtApp()
|
||||||
const dialog = ref(false)
|
const dialog = ref(false)
|
||||||
const models = useModels()
|
const models = useModels()
|
||||||
const currentModel = useCurrentModel()
|
const currentModel = useCurrentModel()
|
||||||
@@ -110,7 +111,7 @@ const removeModel = (index) => {
|
|||||||
}
|
}
|
||||||
const save = async () => {
|
const save = async () => {
|
||||||
if (!currentModel.value) {
|
if (!currentModel.value) {
|
||||||
showWarning('Please select at least one model.')
|
showWarning($i18n.t('pleaseSelectAtLeastOneModelDot'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
setModels(models.value)
|
setModels(models.value)
|
||||||
|
|||||||
191
components/ModelParameters.vue
Normal file
191
components/ModelParameters.vue
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
<script setup>
|
||||||
|
const dialog = ref(false)
|
||||||
|
const currentModel = useCurrentModel()
|
||||||
|
const availableModels = [
|
||||||
|
DEFAULT_MODEL.name
|
||||||
|
]
|
||||||
|
|
||||||
|
watch(currentModel, (newVal, oldVal) => {
|
||||||
|
saveCurrentModel(newVal)
|
||||||
|
}, { deep: true })
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<v-dialog
|
||||||
|
v-model="dialog"
|
||||||
|
persistent
|
||||||
|
>
|
||||||
|
<template v-slot:activator="{ props }">
|
||||||
|
<v-list-item
|
||||||
|
v-bind="props"
|
||||||
|
rounded="xl"
|
||||||
|
prepend-icon="tune"
|
||||||
|
:title="$t('modelParameters')"
|
||||||
|
></v-list-item>
|
||||||
|
</template>
|
||||||
|
<v-card>
|
||||||
|
<v-toolbar
|
||||||
|
density="compact"
|
||||||
|
>
|
||||||
|
<v-toolbar-title>{{ $t('modelParameters') }}</v-toolbar-title>
|
||||||
|
|
||||||
|
<v-spacer></v-spacer>
|
||||||
|
|
||||||
|
<v-btn icon="close" @click="dialog = false"></v-btn>
|
||||||
|
</v-toolbar>
|
||||||
|
<v-card-text>
|
||||||
|
<v-select
|
||||||
|
v-model="currentModel.name"
|
||||||
|
:label="$t('model')"
|
||||||
|
:items="availableModels"
|
||||||
|
variant="underlined"
|
||||||
|
></v-select>
|
||||||
|
|
||||||
|
<v-row
|
||||||
|
no-gutters
|
||||||
|
>
|
||||||
|
<v-col cols="12">
|
||||||
|
<div class="d-flex justify-space-between align-center">
|
||||||
|
<v-list-subheader>{{ $t('temperature') }}</v-list-subheader>
|
||||||
|
<v-text-field
|
||||||
|
v-model="currentModel.temperature"
|
||||||
|
hide-details
|
||||||
|
single-line
|
||||||
|
density="compact"
|
||||||
|
type="number"
|
||||||
|
max="1"
|
||||||
|
step="0.01"
|
||||||
|
style="width: 100px"
|
||||||
|
class="flex-grow-0"
|
||||||
|
></v-text-field>
|
||||||
|
</div>
|
||||||
|
</v-col>
|
||||||
|
<v-col cols="12">
|
||||||
|
<v-slider
|
||||||
|
v-model="currentModel.temperature"
|
||||||
|
:max="1"
|
||||||
|
:step="0.01"
|
||||||
|
hide-details
|
||||||
|
>
|
||||||
|
</v-slider>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
<v-row
|
||||||
|
no-gutters
|
||||||
|
>
|
||||||
|
<v-col cols="12">
|
||||||
|
<div class="d-flex justify-space-between align-center">
|
||||||
|
<v-list-subheader>{{ $t('maxTokens') }}</v-list-subheader>
|
||||||
|
<v-text-field
|
||||||
|
v-model="currentModel.max_tokens"
|
||||||
|
hide-details
|
||||||
|
single-line
|
||||||
|
density="compact"
|
||||||
|
type="number"
|
||||||
|
max="2048"
|
||||||
|
step="1"
|
||||||
|
style="width: 100px"
|
||||||
|
class="flex-grow-0"
|
||||||
|
></v-text-field>
|
||||||
|
</div>
|
||||||
|
</v-col>
|
||||||
|
<v-col cols="12">
|
||||||
|
<v-slider
|
||||||
|
v-model="currentModel.max_tokens"
|
||||||
|
:max="2048"
|
||||||
|
:step="1"
|
||||||
|
hide-details
|
||||||
|
>
|
||||||
|
</v-slider>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
<v-row
|
||||||
|
no-gutters
|
||||||
|
>
|
||||||
|
<v-col cols="12">
|
||||||
|
<div class="d-flex justify-space-between align-center">
|
||||||
|
<v-list-subheader>{{ $t('topP') }}</v-list-subheader>
|
||||||
|
<v-text-field
|
||||||
|
v-model="currentModel.top_p"
|
||||||
|
hide-details
|
||||||
|
single-line
|
||||||
|
density="compact"
|
||||||
|
type="number"
|
||||||
|
max="1"
|
||||||
|
step="0.01"
|
||||||
|
style="width: 100px"
|
||||||
|
class="flex-grow-0"
|
||||||
|
></v-text-field>
|
||||||
|
</div>
|
||||||
|
</v-col>
|
||||||
|
<v-col cols="12">
|
||||||
|
<v-slider
|
||||||
|
v-model="currentModel.top_p"
|
||||||
|
:max="1"
|
||||||
|
:step="0.01"
|
||||||
|
hide-details
|
||||||
|
>
|
||||||
|
</v-slider>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
<v-row no-gutters>
|
||||||
|
<v-col cols="12">
|
||||||
|
<div class="d-flex justify-space-between align-center">
|
||||||
|
<v-list-subheader>{{ $t('frequencyPenalty') }}</v-list-subheader>
|
||||||
|
<v-text-field
|
||||||
|
v-model="currentModel.frequency_penalty"
|
||||||
|
hide-details
|
||||||
|
single-line
|
||||||
|
density="compact"
|
||||||
|
type="number"
|
||||||
|
max="2"
|
||||||
|
step="0.01"
|
||||||
|
style="width: 100px"
|
||||||
|
class="flex-grow-0"
|
||||||
|
></v-text-field>
|
||||||
|
</div>
|
||||||
|
</v-col>
|
||||||
|
<v-col cols="12">
|
||||||
|
<v-slider
|
||||||
|
v-model="currentModel.frequency_penalty"
|
||||||
|
:max="2"
|
||||||
|
:step="0.01"
|
||||||
|
hide-details
|
||||||
|
></v-slider>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
<v-row no-gutters>
|
||||||
|
<v-col cols="12">
|
||||||
|
<div class="d-flex justify-space-between align-center">
|
||||||
|
<v-list-subheader>{{ $t('presencePenalty') }}</v-list-subheader>
|
||||||
|
<v-text-field
|
||||||
|
v-model="currentModel.presence_penalty"
|
||||||
|
hide-details
|
||||||
|
single-line
|
||||||
|
density="compact"
|
||||||
|
type="number"
|
||||||
|
max="2"
|
||||||
|
step="0.01"
|
||||||
|
style="width: 100px"
|
||||||
|
class="flex-grow-0"
|
||||||
|
></v-text-field>
|
||||||
|
</div>
|
||||||
|
</v-col>
|
||||||
|
<v-col cols="12">
|
||||||
|
<v-slider
|
||||||
|
v-model="currentModel.presence_penalty"
|
||||||
|
:max="2"
|
||||||
|
:step="0.01"
|
||||||
|
hide-details
|
||||||
|
></v-slider>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
|
</v-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
|
||||||
|
</style>
|
||||||
@@ -1,25 +1,83 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { marked } from "marked"
|
|
||||||
import hljs from "highlight.js"
|
import hljs from "highlight.js"
|
||||||
|
import MarkdownIt from 'markdown-it'
|
||||||
|
import copy from 'copy-to-clipboard'
|
||||||
|
|
||||||
marked.setOptions({
|
|
||||||
highlight: function (code, lang) {
|
const md = new MarkdownIt({
|
||||||
|
linkify: true,
|
||||||
|
highlight(code, lang) {
|
||||||
const language = hljs.getLanguage(lang) ? lang : 'plaintext'
|
const language = hljs.getLanguage(lang) ? lang : 'plaintext'
|
||||||
return hljs.highlight(code, { language }).value
|
return `<pre class="hljs-code-container my-3"><div class="hljs-code-header d-flex align-center justify-space-between bg-grey-darken-3 pa-1"><span class="pl-2 text-caption">${language}</span><button class="hljs-copy-button" data-copied="false">Copy</button></div><code class="hljs language-${language}">${hljs.highlight(code, { language: language, ignoreIllegals: true }).value}</code></pre>`
|
||||||
},
|
},
|
||||||
langPrefix: 'hljs language-', // highlight.js css class prefix
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const props = defineProps(['content'])
|
const props = defineProps(['content'])
|
||||||
const contentHtml = computed(() => {
|
|
||||||
return props.content ? marked(props.content) : ''
|
const contentHtml = ref('')
|
||||||
|
|
||||||
|
const contentElm = ref(null)
|
||||||
|
|
||||||
|
watchEffect(() => {
|
||||||
|
contentHtml.value = props.content ? md.render(props.content) : ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const bindCopyCodeToButtons = () => {
|
||||||
|
if (!contentElm.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
contentElm.value.querySelectorAll('.hljs-code-container').forEach((codeContainer) => {
|
||||||
|
const copyButton = codeContainer.querySelector('.hljs-copy-button');
|
||||||
|
const codeBody = codeContainer.querySelector('code');
|
||||||
|
copyButton.onclick = function () {
|
||||||
|
copy(codeBody.textContent ?? '');
|
||||||
|
|
||||||
|
copyButton.innerHTML = "Copied!";
|
||||||
|
copyButton.dataset.copied = 'true';
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
copyButton.innerHTML = "Copy";
|
||||||
|
copyButton.dataset.copied = 'false';
|
||||||
|
}, 2000);
|
||||||
|
};
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
bindCopyCodeToButtons()
|
||||||
|
})
|
||||||
|
|
||||||
|
onUpdated(() => {
|
||||||
|
bindCopyCodeToButtons()
|
||||||
})
|
})
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
|
ref="contentElm"
|
||||||
v-html="contentHtml"
|
v-html="contentHtml"
|
||||||
|
class="chat-msg-content"
|
||||||
></div>
|
></div>
|
||||||
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.chat-msg-content ol {
|
||||||
|
padding-left: 2em;
|
||||||
|
}
|
||||||
|
.hljs-code-container {
|
||||||
|
border-radius: 3px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.hljs-copy-button{
|
||||||
|
width:2rem;height:2rem;text-indent:-9999px;color:#fff;
|
||||||
|
border-radius:.25rem;border:1px solid #ffffff22;
|
||||||
|
background-image:url('data:image/svg+xml;utf-8,<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" clip-rule="evenodd" d="M6 5C5.73478 5 5.48043 5.10536 5.29289 5.29289C5.10536 5.48043 5 5.73478 5 6V20C5 20.2652 5.10536 20.5196 5.29289 20.7071C5.48043 20.8946 5.73478 21 6 21H18C18.2652 21 18.5196 20.8946 18.7071 20.7071C18.8946 20.5196 19 20.2652 19 20V6C19 5.73478 18.8946 5.48043 18.7071 5.29289C18.5196 5.10536 18.2652 5 18 5H16C15.4477 5 15 4.55228 15 4C15 3.44772 15.4477 3 16 3H18C18.7956 3 19.5587 3.31607 20.1213 3.87868C20.6839 4.44129 21 5.20435 21 6V20C21 20.7957 20.6839 21.5587 20.1213 22.1213C19.5587 22.6839 18.7957 23 18 23H6C5.20435 23 4.44129 22.6839 3.87868 22.1213C3.31607 21.5587 3 20.7957 3 20V6C3 5.20435 3.31607 4.44129 3.87868 3.87868C4.44129 3.31607 5.20435 3 6 3H8C8.55228 3 9 3.44772 9 4C9 4.55228 8.55228 5 8 5H6Z" fill="white"/><path fill-rule="evenodd" clip-rule="evenodd" d="M7 3C7 1.89543 7.89543 1 9 1H15C16.1046 1 17 1.89543 17 3V5C17 6.10457 16.1046 7 15 7H9C7.89543 7 7 6.10457 7 5V3ZM15 3H9V5H15V3Z" fill="white"/></svg>');
|
||||||
|
background-repeat:no-repeat;background-position:center;
|
||||||
|
transition:background-color 200ms ease,transform 200ms ease-out
|
||||||
|
}
|
||||||
|
.hljs-copy-button:hover{border-color:#ffffff44}
|
||||||
|
.hljs-copy-button:active{border-color:#ffffff66}
|
||||||
|
.hljs-copy-button[data-copied="true"]{text-indent:0;width:auto;background-image:none}
|
||||||
|
@media(prefers-reduced-motion){.hljs-copy-button{transition:none}}
|
||||||
|
</style>
|
||||||
@@ -1,21 +1,21 @@
|
|||||||
<template>
|
<template>
|
||||||
<v-textarea
|
<v-textarea
|
||||||
v-model="message"
|
v-model="message"
|
||||||
clearable
|
:label="$t('writeAMessage')"
|
||||||
label="Message"
|
:placeholder="hint"
|
||||||
placeholder="Type your message here"
|
|
||||||
rows="1"
|
rows="1"
|
||||||
:auto-grow="autoGrow"
|
:auto-grow="autoGrow"
|
||||||
:disabled="disabled"
|
:disabled="disabled"
|
||||||
:loading="loading"
|
:loading="loading"
|
||||||
hide-details
|
:hide-details="true"
|
||||||
append-inner-icon="send"
|
append-inner-icon="send"
|
||||||
@keyup.enter="send"
|
@keyup.enter.exact="enterOnly"
|
||||||
@click:append="send"
|
@click:appendInner="clickSendBtn"
|
||||||
></v-textarea>
|
></v-textarea>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
import { isMobile } from 'is-mobile'
|
||||||
export default {
|
export default {
|
||||||
name: "MsgEditor",
|
name: "MsgEditor",
|
||||||
props: {
|
props: {
|
||||||
@@ -30,6 +30,11 @@ export default {
|
|||||||
autoGrow: true,
|
autoGrow: true,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
computed: {
|
||||||
|
hint() {
|
||||||
|
return isMobile() ? "" : "Press Enter to send your message or Shift+Enter to add a new line";
|
||||||
|
},
|
||||||
|
},
|
||||||
watch: {
|
watch: {
|
||||||
message(val) {
|
message(val) {
|
||||||
const lines = val.split(/\r\n|\r|\n/).length;
|
const lines = val.split(/\r\n|\r|\n/).length;
|
||||||
@@ -44,10 +49,27 @@ export default {
|
|||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
send() {
|
send() {
|
||||||
const msg = this.message
|
let msg = this.message
|
||||||
|
// remove the last "\n"
|
||||||
|
if (msg[msg.length - 1] === "\n") {
|
||||||
|
msg = msg.slice(0, -1)
|
||||||
|
}
|
||||||
|
if (msg.length > 0) {
|
||||||
|
this.sendMessage(msg)
|
||||||
|
}
|
||||||
this.message = ""
|
this.message = ""
|
||||||
this.sendMessage(msg);
|
|
||||||
},
|
},
|
||||||
|
usePrompt(prompt) {
|
||||||
|
this.message = prompt
|
||||||
|
},
|
||||||
|
clickSendBtn () {
|
||||||
|
this.send()
|
||||||
|
},
|
||||||
|
enterOnly () {
|
||||||
|
if (!isMobile()) {
|
||||||
|
this.send()
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
224
components/Prompt.vue
Normal file
224
components/Prompt.vue
Normal file
@@ -0,0 +1,224 @@
|
|||||||
|
<script setup>
|
||||||
|
const menu = ref(false)
|
||||||
|
const prompts = ref([])
|
||||||
|
const editingPrompt = ref(null)
|
||||||
|
const newPrompt = ref('')
|
||||||
|
const submittingNewPrompt = ref(false)
|
||||||
|
const promptInputErrorMessage = ref('')
|
||||||
|
const loadingPrompts = ref(false)
|
||||||
|
const deletingPromptIndex = ref(null)
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
usePrompt: {
|
||||||
|
type: Function,
|
||||||
|
required: true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const addPrompt = async () => {
|
||||||
|
if (!newPrompt.value) {
|
||||||
|
promptInputErrorMessage.value = 'Please enter a prompt'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
submittingNewPrompt.value = true
|
||||||
|
const { data, error } = await useAuthFetch('/api/chat/prompts/', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
prompt: newPrompt.value
|
||||||
|
})
|
||||||
|
})
|
||||||
|
if (!error.value) {
|
||||||
|
prompts.value.push(data.value)
|
||||||
|
newPrompt.value = ''
|
||||||
|
}
|
||||||
|
submittingNewPrompt.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const editPrompt = (index) => {
|
||||||
|
editingPrompt.value = Object.assign({}, prompts.value[index])
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatePrompt = async (index) => {
|
||||||
|
editingPrompt.value.updating = true
|
||||||
|
const { data, error } = await useAuthFetch(`/api/chat/prompts/${editingPrompt.value.id}/`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({
|
||||||
|
prompt: editingPrompt.value.prompt
|
||||||
|
})
|
||||||
|
})
|
||||||
|
if (!error.value) {
|
||||||
|
prompts.value[index] = editingPrompt.value
|
||||||
|
}
|
||||||
|
editingPrompt.value.updating = false
|
||||||
|
editingPrompt.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
const cancelEditPrompt = () => {
|
||||||
|
editingPrompt.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
const deletePrompt = async (index) => {
|
||||||
|
deletingPromptIndex.value = index
|
||||||
|
const { data, error } = await useAuthFetch(`/api/chat/prompts/${prompts.value[index].id}/`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
})
|
||||||
|
deletingPromptIndex.value = null
|
||||||
|
if (!error.value) {
|
||||||
|
prompts.value.splice(index, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadPrompts = async () => {
|
||||||
|
loadingPrompts.value = true
|
||||||
|
const { data, error } = await useAuthFetch('/api/chat/prompts/')
|
||||||
|
if (!error.value) {
|
||||||
|
prompts.value = data.value
|
||||||
|
}
|
||||||
|
loadingPrompts.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectPrompt = (prompt) => {
|
||||||
|
props.usePrompt(prompt.prompt)
|
||||||
|
menu.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted( () => {
|
||||||
|
loadPrompts()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<v-menu
|
||||||
|
v-model="menu"
|
||||||
|
:close-on-content-click="false"
|
||||||
|
>
|
||||||
|
<template v-slot:activator="{ props }">
|
||||||
|
<v-btn
|
||||||
|
v-bind="props"
|
||||||
|
icon="speaker_notes"
|
||||||
|
title="Common prompts"
|
||||||
|
class="mr-3"
|
||||||
|
></v-btn>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<v-container>
|
||||||
|
<v-card
|
||||||
|
min-width="300"
|
||||||
|
max-width="500"
|
||||||
|
>
|
||||||
|
<v-card-title>
|
||||||
|
<span class="headline">Frequently prompts</span>
|
||||||
|
</v-card-title>
|
||||||
|
|
||||||
|
<v-divider></v-divider>
|
||||||
|
|
||||||
|
<v-list>
|
||||||
|
<v-list-item v-show="loadingPrompts">
|
||||||
|
<v-list-item-title class="d-flex justify-center">
|
||||||
|
<v-progress-circular indeterminate></v-progress-circular>
|
||||||
|
</v-list-item-title>
|
||||||
|
</v-list-item>
|
||||||
|
<template
|
||||||
|
v-for="(prompt, idx) in prompts"
|
||||||
|
:key="prompt.id"
|
||||||
|
>
|
||||||
|
<v-list-item
|
||||||
|
active-color="primary"
|
||||||
|
rounded="xl"
|
||||||
|
v-if="editingPrompt && editingPrompt.id === prompt.id"
|
||||||
|
>
|
||||||
|
<v-textarea
|
||||||
|
rows="2"
|
||||||
|
v-model="editingPrompt.prompt"
|
||||||
|
:loading="editingPrompt.updating"
|
||||||
|
variant="underlined"
|
||||||
|
hide-details
|
||||||
|
density="compact"
|
||||||
|
>
|
||||||
|
<template v-slot:append>
|
||||||
|
<div class="d-flex flex-column">
|
||||||
|
<v-btn
|
||||||
|
icon="done"
|
||||||
|
variant="text"
|
||||||
|
:loading="editingPrompt.updating"
|
||||||
|
@click="updatePrompt(idx)"
|
||||||
|
>
|
||||||
|
</v-btn>
|
||||||
|
<v-btn
|
||||||
|
icon="close"
|
||||||
|
variant="text"
|
||||||
|
@click="cancelEditPrompt()"
|
||||||
|
>
|
||||||
|
</v-btn>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</v-textarea>
|
||||||
|
</v-list-item>
|
||||||
|
<v-list-item
|
||||||
|
v-if="!editingPrompt || editingPrompt.id !== prompt.id"
|
||||||
|
rounded="xl"
|
||||||
|
active-color="primary"
|
||||||
|
@click="selectPrompt(prompt)"
|
||||||
|
>
|
||||||
|
<v-list-item-title>{{ prompt.prompt }}</v-list-item-title>
|
||||||
|
<template v-slot:append>
|
||||||
|
<v-btn
|
||||||
|
icon="edit"
|
||||||
|
size="small"
|
||||||
|
variant="text"
|
||||||
|
@click="editPrompt(idx)"
|
||||||
|
>
|
||||||
|
</v-btn>
|
||||||
|
<v-btn
|
||||||
|
icon="delete"
|
||||||
|
size="small"
|
||||||
|
variant="text"
|
||||||
|
:loading="deletingPromptIndex === idx"
|
||||||
|
@click="deletePrompt(idx)"
|
||||||
|
>
|
||||||
|
</v-btn>
|
||||||
|
</template>
|
||||||
|
</v-list-item>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<v-list-item
|
||||||
|
active-color="primary"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="pt-3"
|
||||||
|
>
|
||||||
|
<v-textarea
|
||||||
|
rows="2"
|
||||||
|
v-model="newPrompt"
|
||||||
|
label="Add a new prompt"
|
||||||
|
variant="outlined"
|
||||||
|
density="compact"
|
||||||
|
:error-messages="promptInputErrorMessage"
|
||||||
|
@update:modelValue="promptInputErrorMessage = ''"
|
||||||
|
clearable
|
||||||
|
>
|
||||||
|
</v-textarea>
|
||||||
|
</div>
|
||||||
|
</v-list-item>
|
||||||
|
<v-list-item>
|
||||||
|
<v-btn
|
||||||
|
variant="text"
|
||||||
|
block
|
||||||
|
:loading="submittingNewPrompt"
|
||||||
|
@click="addPrompt()"
|
||||||
|
>
|
||||||
|
<v-icon icon="add"></v-icon>
|
||||||
|
Add prompt
|
||||||
|
</v-btn>
|
||||||
|
</v-list-item>
|
||||||
|
</v-list>
|
||||||
|
</v-card>
|
||||||
|
</v-container>
|
||||||
|
</v-menu>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
|
||||||
|
</style>
|
||||||
84
components/Welcome.vue
Normal file
84
components/Welcome.vue
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
<template>
|
||||||
|
<v-container>
|
||||||
|
<v-row>
|
||||||
|
<v-col cols="12">
|
||||||
|
<div class="text-center">
|
||||||
|
<h2 class="text-h2">{{ $t('welcomeTo') }} <span class="text-primary">{{ runtimeConfig.public.appName }}</span></h2>
|
||||||
|
<p class="text-caption mt-5">
|
||||||
|
{{ runtimeConfig.public.appName }} {{ $t('welcomeScreen.introduction1') }}
|
||||||
|
<br>
|
||||||
|
{{ $t('welcomeScreen.introduction2') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
<v-row>
|
||||||
|
<v-col cols="12" md="10" offset-md="1">
|
||||||
|
<v-row>
|
||||||
|
<v-col
|
||||||
|
cols="12"
|
||||||
|
md="4"
|
||||||
|
>
|
||||||
|
<v-row>
|
||||||
|
<v-col>
|
||||||
|
<div class="d-flex flex-column align-center">
|
||||||
|
<v-icon icon="sunny"></v-icon>
|
||||||
|
<h3 class="text-h6">{{ $t('welcomeScreen.examples.title') }}</h3>
|
||||||
|
</div>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
<WelcomeCard v-for="example in examples" :content="example" />
|
||||||
|
</v-col>
|
||||||
|
<v-col
|
||||||
|
cols="12"
|
||||||
|
md="4"
|
||||||
|
>
|
||||||
|
<v-row>
|
||||||
|
<v-col>
|
||||||
|
<div class="d-flex flex-column align-center">
|
||||||
|
<v-icon icon="bolt"></v-icon>
|
||||||
|
<h3 class="text-h6">{{ $t('welcomeScreen.capabilities.title') }}</h3>
|
||||||
|
</div>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
<WelcomeCard v-for="capabilitie in capabilities" :content="capabilitie" />
|
||||||
|
</v-col>
|
||||||
|
<v-col
|
||||||
|
cols="12"
|
||||||
|
md="4"
|
||||||
|
>
|
||||||
|
<v-row>
|
||||||
|
<v-col>
|
||||||
|
<div class="d-flex flex-column align-center">
|
||||||
|
<v-icon icon="warning_amber"></v-icon>
|
||||||
|
<h3 class="text-h6">{{ $t('welcomeScreen.limitations.title') }}</h3>
|
||||||
|
</div>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
<WelcomeCard v-for="limitation in limitations" :content="limitation" />
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
</v-container>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
const runtimeConfig = useRuntimeConfig()
|
||||||
|
const { $i18n } = useNuxtApp()
|
||||||
|
const examples = ref([
|
||||||
|
$i18n.t('welcomeScreen.examples.item1'),
|
||||||
|
$i18n.t('welcomeScreen.examples.item2'),
|
||||||
|
$i18n.t('welcomeScreen.examples.item3')
|
||||||
|
])
|
||||||
|
const capabilities = ref([
|
||||||
|
$i18n.t('welcomeScreen.capabilities.item1'),
|
||||||
|
$i18n.t('welcomeScreen.capabilities.item2'),
|
||||||
|
$i18n.t('welcomeScreen.capabilities.item3')
|
||||||
|
])
|
||||||
|
const limitations = ref([
|
||||||
|
$i18n.t('welcomeScreen.limitations.item1'),
|
||||||
|
$i18n.t('welcomeScreen.limitations.item2'),
|
||||||
|
$i18n.t('welcomeScreen.limitations.item3')
|
||||||
|
])
|
||||||
|
</script>
|
||||||
24
components/WelcomeCard.vue
Normal file
24
components/WelcomeCard.vue
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
<template>
|
||||||
|
<v-row>
|
||||||
|
<v-col>
|
||||||
|
<v-hover
|
||||||
|
v-slot="{ isHovering, props }"
|
||||||
|
open-delay="100"
|
||||||
|
>
|
||||||
|
<v-card
|
||||||
|
:elevation="isHovering ? 3 : 0"
|
||||||
|
v-bind="props"
|
||||||
|
variant="tonal"
|
||||||
|
>
|
||||||
|
<v-card-text class="text-center">
|
||||||
|
{{ content }}
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
|
</v-hover>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
const props = defineProps(['content'])
|
||||||
|
</script>
|
||||||
86
components/settings/Languages.vue
Normal file
86
components/settings/Languages.vue
Normal 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>
|
||||||
@@ -1,5 +1,10 @@
|
|||||||
|
|
||||||
export const useModels = () => useState('models', () => getStoredModels())
|
export const useModels = () => useState('models', () => getStoredModels())
|
||||||
|
|
||||||
export const useCurrentModel = () => useState('currentModel', () => getCurrentModel())
|
export const useCurrentModel = () => useState('currentModel', () => getCurrentModel())
|
||||||
|
|
||||||
export const useApiKey = () => useState('apiKey', () => getStoredApiKey())
|
export const useApiKey = () => useState('apiKey', () => getStoredApiKey())
|
||||||
|
|
||||||
|
export const useConversion = () => useState('conversion', () => getDefaultConversionData())
|
||||||
|
|
||||||
|
export const useConversions = () => useState('conversions', () => [])
|
||||||
9
composables/useAuthFetch.js
Normal file
9
composables/useAuthFetch.js
Normal 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
|
||||||
|
}
|
||||||
BIN
demos/demo.gif
BIN
demos/demo.gif
Binary file not shown.
|
Before Width: | Height: | Size: 917 KiB After Width: | Height: | Size: 144 KiB |
BIN
demos/demo.png
Normal file
BIN
demos/demo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 47 KiB |
70
deployment.sh
Normal file
70
deployment.sh
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
read -p "Please enter a domain name or external IP address [default: localhost]: " APP_DOMAIN
|
||||||
|
|
||||||
|
if [ -z "$APP_DOMAIN" ]; then
|
||||||
|
APP_DOMAIN="localhost"
|
||||||
|
fi
|
||||||
|
|
||||||
|
read -p "Please set a port for the frontend server [default: 80]: " CLIENT_PORT
|
||||||
|
|
||||||
|
if [ -z "$CLIENT_PORT" ]; then
|
||||||
|
CLIENT_PORT="80"
|
||||||
|
fi
|
||||||
|
|
||||||
|
read -p "Please set a port for the backend server [default: 9000]: " SERVER_PORT
|
||||||
|
|
||||||
|
if [ -z "$SERVER_PORT" ]; then
|
||||||
|
SERVER_PORT="9000"
|
||||||
|
fi
|
||||||
|
|
||||||
|
read -p "Please set a port for the backend WSGI server [default: 8000]: " WSGI_PORT
|
||||||
|
|
||||||
|
if [ -z "$WSGI_PORT" ]; then
|
||||||
|
WSGI_PORT="8000"
|
||||||
|
fi
|
||||||
|
|
||||||
|
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="${APP_DOMAIN}:${SERVER_PORT}" CLIENT_PORT=${CLIENT_PORT} SERVER_PORT=${SERVER_PORT} WSGI_PORT=${WSGI_PORT} docker-compose up --pull -d
|
||||||
|
|
||||||
|
echo "Done"
|
||||||
@@ -1,8 +1,50 @@
|
|||||||
version: '3'
|
version: '3'
|
||||||
services:
|
services:
|
||||||
app:
|
client:
|
||||||
build:
|
image: wongsaang/chatgpt-ui-client:latest
|
||||||
context: .
|
environment:
|
||||||
dockerfile: ./Dockerfile
|
- SERVER_DOMAIN=http://backend-web-server
|
||||||
|
- NUXT_PUBLIC_APP_NAME='ChatGPT UI'
|
||||||
|
- NUXT_PUBLIC_TYPEWRITER=true
|
||||||
|
- NUXT_PUBLIC_TYPEWRITER_DELAY=100
|
||||||
|
depends_on:
|
||||||
|
- backend-web-server
|
||||||
ports:
|
ports:
|
||||||
- '${APP_PORT:-80}:80'
|
- '${CLIENT_PORT:-80}:80'
|
||||||
|
networks:
|
||||||
|
- chatgpt_ui_network
|
||||||
|
restart: always
|
||||||
|
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:
|
||||||
|
- '${WSGI_PORT:-8000}:8000'
|
||||||
|
networks:
|
||||||
|
- chatgpt_ui_network
|
||||||
|
restart: always
|
||||||
|
backend-web-server:
|
||||||
|
image: wongsaang/chatgpt-ui-web-server:latest
|
||||||
|
environment:
|
||||||
|
- BACKEND_URL=http://backend-wsgi-server:8000
|
||||||
|
ports:
|
||||||
|
- '${SERVER_PORT:-9000}:80'
|
||||||
|
depends_on:
|
||||||
|
- backend-wsgi-server
|
||||||
|
networks:
|
||||||
|
- chatgpt_ui_network
|
||||||
|
restart: always
|
||||||
|
|
||||||
|
networks:
|
||||||
|
chatgpt_ui_network:
|
||||||
|
driver: bridge
|
||||||
181
docs/zh/README.md
Normal file
181
docs/zh/README.md
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
<p align="center">
|
||||||
|
<img alt="demo" src="../../demos/demo.gif?v=1">
|
||||||
|
</p>
|
||||||
|
|
||||||
|
[English](../../README.md) | [中文](./docs/zh/README.md)
|
||||||
|
|
||||||
|
# ChatGPT UI
|
||||||
|
|
||||||
|
ChatGPT Web 客户端,支持多用户,支持 Mysql、PostgreSQL 等多种数据库连接进行数据持久化存储,支持多语言。提供 Docker 镜像和快速部署脚本。
|
||||||
|
|
||||||
|
## 📢 更新
|
||||||
|
<details open>
|
||||||
|
<summary><strong>2023-03-15</strong></summary>
|
||||||
|
|
||||||
|
在管理后台增加 `open_registration` 设置项,用于控制是否开放用户注册。你可以登录管理后台,在 `Chat->Setting` 中看到这个设置项,默认是 `True` (允许用户注册),如果不需要,请改成 `False`。
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details open>
|
||||||
|
<summary><strong>2023-03-10</strong></summary>
|
||||||
|
|
||||||
|
增加 2 个环境变量来控制打字机效果, 详见下方 docker-compose 配置的环境变量说明
|
||||||
|
|
||||||
|
- `NUXT_PUBLIC_TYPEWRITER` 是否开启打字机效果
|
||||||
|
- `NUXT_PUBLIC_TYPEWRITER_DELAY` 每个字的延迟时间,单位:毫秒
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details open>
|
||||||
|
<summary><strong>2023-03-04</strong></summary>
|
||||||
|
|
||||||
|
**使用最新的官方聊天模型** `gpt-3.5-turbo`
|
||||||
|
|
||||||
|
**🎉🎉🎉 提供一个 shell 脚本,用于快速部署到服务器** [使用方法](#one-click-depolyment)
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
|
||||||
|
<summary><strong>2023-02-24</strong></summary>
|
||||||
|
V2 是一个重要的更新,将后端功能分离为一个独立的项目,托管在 [chatgpt-ui-server](https://github.com/WongSaang/chatgpt-ui-server), 该项目使用基于 Python 的 Django 框架。
|
||||||
|
|
||||||
|
如果您仍然希望使用旧版本,请访问 [v1 branch](https://github.com/WongSaang/chatgpt-ui/tree/v1) (不推荐,不再更新).
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
## V2 的功能特性:
|
||||||
|
|
||||||
|
- 😉 前后端分离,后端使用基于 Python 的 Django 框架。
|
||||||
|
- 😘 用户身份验证,支持多个用户。
|
||||||
|
- 😀 能够将数据存储在外部数据库中,支持 Mysql、PostgreSQL 等数据库(默认为 Sqlite)。
|
||||||
|
- 😎 持续对话,让AI根据上下文回答问题。
|
||||||
|
|
||||||
|
|
||||||
|
## 🚀 一行命令部署到服务器 <a name="one-click-depolyment"></a>
|
||||||
|
|
||||||
|
注意:此脚本仅在 Ubuntu Server 22.04 LTS 上验证过。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash <(curl -Ls https://raw.githubusercontent.com/WongSaang/chatgpt-ui/main/deployment.sh)
|
||||||
|
```
|
||||||
|
|
||||||
|
> 如果您拥有一个域名,可以使用 DNS 解析将其指向服务器的 IP 地址。当然,直接使用服务器的 IP 地址也是可以的。
|
||||||
|
> 在脚本执行期间,会提示您输入域名。如果您没有域名,可以直接输入服务器的 IP 地址。
|
||||||
|
|
||||||
|
### 部署完成之后
|
||||||
|
|
||||||
|
访问 `http(s)://your.domain:9000/admin` / IP `http(s)://123.123.123.123:9000/admin` 登录管理面板。
|
||||||
|
|
||||||
|
默认超级用户: `admin`
|
||||||
|
|
||||||
|
默认密码: `password`
|
||||||
|
|
||||||
|
在可以开始聊天之前,您需要添加一个 OpenAI 的 API 密钥。在管理面板的设置模型中,添加一个名称为 openai_api_key 的记录,将值设置为您的 API 密钥。
|
||||||
|
|
||||||
|
现在可以访问客户端地址 `http(s)://your.domain` / `http://123.123.123.123` 开始聊天。
|
||||||
|
|
||||||
|
🎉🎉🎉 享受吧!
|
||||||
|
|
||||||
|
## 通过 Docker Compose 快速开始
|
||||||
|
|
||||||
|
以下是一个 docker-compose.yml 模板,您可以使用它来快速启动服务。
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
version: '3'
|
||||||
|
services:
|
||||||
|
client:
|
||||||
|
image: wongsaang/chatgpt-ui-client:latest
|
||||||
|
environment:
|
||||||
|
- SERVER_DOMAIN=http://backend-web-server
|
||||||
|
- NUXT_PUBLIC_APP_NAME='ChatGPT UI' # App 名称,默认为 ChatGPT UI
|
||||||
|
- NUXT_PUBLIC_TYPEWRITER=true # 是否启用打字机效果,默认关闭
|
||||||
|
- NUXT_PUBLIC_TYPEWRITER_DELAY=100 # 打字机效果的延迟时间,默认 50毫秒
|
||||||
|
depends_on:
|
||||||
|
- backend-web-server
|
||||||
|
ports:
|
||||||
|
- '80:80'
|
||||||
|
networks:
|
||||||
|
- chatgpt_ui_network
|
||||||
|
backend-wsgi-server:
|
||||||
|
image: wongsaang/chatgpt-ui-wsgi-server:latest
|
||||||
|
environment:
|
||||||
|
- APP_DOMAIN=${APP_DOMAIN:-localhost:9000} # CSRF 白名单,在这里设置为 chatgpt-ui-web-server 的地址+端口, 默认: localhost:9000
|
||||||
|
#- DB_URL=postgres://postgres:postgrespw@localhost:49153/chatgpt # 连接外部数据库,如果不设置这个参数,则默认使用内置的 Sqlite。需要注意的是,如果不连接外部数据库,数据将在容器销毁后丢失。链接格式请看下面的 DB_URL 格式对照表
|
||||||
|
#- OPENAI_API_PROXY=https://openai.proxy.com/v1 # https://api.openai.com/v1 的代理地址
|
||||||
|
- DJANGO_SUPERUSER_USERNAME=admin # 默认超级用户
|
||||||
|
- DJANGO_SUPERUSER_PASSWORD=password # 默认超级用户的密码
|
||||||
|
- DJANGO_SUPERUSER_EMAIL=admin@example.com # 默认超级用户邮箱
|
||||||
|
# 如果您想使用电子邮件验证功能,需要配置以下参数:
|
||||||
|
# - 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
|
||||||
|
```
|
||||||
|
|
||||||
|
### DB_URL 格式对照表
|
||||||
|
|
||||||
|
| 数据库 | 链接 |
|
||||||
|
|----------------------|--------------------------------------------------|
|
||||||
|
| PostgreSQL | ``postgres://USER:PASSWORD@HOST:PORT/NAME`` |
|
||||||
|
| MySQL | ``mysql://USER:PASSWORD@HOST:PORT/NAME`` |
|
||||||
|
| SQLite | ``sqlite:///PATH`` |
|
||||||
|
|
||||||
|
### 设置 API 密钥
|
||||||
|
|
||||||
|
访问 `http(s)://your.domain:9000/admin` / IP `http(s)://123.123.123.123:9000/admin` 登录管理面板。
|
||||||
|
|
||||||
|
默认超级用户: `admin`
|
||||||
|
|
||||||
|
默认密码: `password`
|
||||||
|
|
||||||
|
在可以开始聊天之前,您需要添加一个 OpenAI 的 API 密钥。在管理面板的设置模型中,添加一个名称为 openai_api_key 的记录,将值设置为您的 API 密钥。
|
||||||
|
|
||||||
|
现在可以访问客户端地址 `http(s)://your.domain` / `http://123.123.123.123` 开始聊天。
|
||||||
|
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
### Setup
|
||||||
|
|
||||||
|
Make sure to install the dependencies:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# yarn
|
||||||
|
yarn install
|
||||||
|
```
|
||||||
|
|
||||||
|
### Development Server
|
||||||
|
|
||||||
|
Start the development server on http://localhost:3000
|
||||||
|
|
||||||
|
```bash
|
||||||
|
yarn dev
|
||||||
|
```
|
||||||
|
|
||||||
|
### Production
|
||||||
|
|
||||||
|
Build the application for production:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
yarn build
|
||||||
|
```
|
||||||
57
lang/en-US.json
Normal file
57
lang/en-US.json
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
{
|
||||||
|
"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",
|
||||||
|
"newConversation": "New conversation",
|
||||||
|
"clearConversations": "Clear conversations",
|
||||||
|
"modelParameters": "Model Parameters",
|
||||||
|
"model": "Model",
|
||||||
|
"temperature": "Temperature",
|
||||||
|
"topP": "Top P",
|
||||||
|
"frequencyPenalty": "Frequency Penalty",
|
||||||
|
"presencePenalty": "Presence Penalty",
|
||||||
|
"maxTokens": "Max Tokens",
|
||||||
|
"roles": {
|
||||||
|
"me": "Me",
|
||||||
|
"ai": "AI"
|
||||||
|
},
|
||||||
|
"copy": "Copy",
|
||||||
|
"copied": "Copied",
|
||||||
|
"delete": "Delete",
|
||||||
|
"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 old’s 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
57
lang/zh-CN.json
Normal file
57
lang/zh-CN.json
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
{
|
||||||
|
"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": "反馈",
|
||||||
|
"newConversation": "新的对话",
|
||||||
|
"clearConversations": "清除对话",
|
||||||
|
"modelParameters": "模型参数",
|
||||||
|
"model": "模型",
|
||||||
|
"temperature": "Temperature",
|
||||||
|
"topP": "Top P",
|
||||||
|
"frequencyPenalty": "Frequency Penalty",
|
||||||
|
"presencePenalty": "Presence Penalty",
|
||||||
|
"maxTokens": "Max Tokens",
|
||||||
|
"roles": {
|
||||||
|
"me": "我",
|
||||||
|
"ai": "AI"
|
||||||
|
},
|
||||||
|
"copy": "复制",
|
||||||
|
"copied": "已复制",
|
||||||
|
"delete": "删除",
|
||||||
|
"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年以后的世界和事件了解有限"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
387
layouts/default.vue
Normal file
387
layouts/default.vue
Normal file
@@ -0,0 +1,387 @@
|
|||||||
|
<script setup>
|
||||||
|
import {useDisplay} from "vuetify";
|
||||||
|
|
||||||
|
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 currentConversation = useConversion()
|
||||||
|
|
||||||
|
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) {
|
||||||
|
if (conversations.value[index].id === currentConversation.value.id) {
|
||||||
|
createNewConversion()
|
||||||
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
const {mdAndUp} = useDisplay()
|
||||||
|
|
||||||
|
const drawerPermanent = computed(() => {
|
||||||
|
return mdAndUp.value
|
||||||
|
})
|
||||||
|
|
||||||
|
onNuxtReady(async () => {
|
||||||
|
loadConversations()
|
||||||
|
})
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<v-app
|
||||||
|
:theme="$colorMode.value"
|
||||||
|
>
|
||||||
|
<v-navigation-drawer
|
||||||
|
v-model="drawer"
|
||||||
|
:permanent="drawerPermanent"
|
||||||
|
width="300"
|
||||||
|
>
|
||||||
|
<div class="px-2 py-2">
|
||||||
|
<v-list>
|
||||||
|
<v-list-item>
|
||||||
|
<v-btn
|
||||||
|
block
|
||||||
|
variant="outlined"
|
||||||
|
prepend-icon="add"
|
||||||
|
@click="createNewConversion()"
|
||||||
|
class="text-none"
|
||||||
|
>
|
||||||
|
{{ $t('newConversation') }}
|
||||||
|
</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"
|
||||||
|
rounded="xl"
|
||||||
|
v-if="editingConversation && editingConversation.id === conversation.id"
|
||||||
|
>
|
||||||
|
<v-text-field
|
||||||
|
v-model="editingConversation.topic"
|
||||||
|
:loading="editingConversation.updating"
|
||||||
|
variant="underlined"
|
||||||
|
append-icon="done"
|
||||||
|
hide-details
|
||||||
|
density="compact"
|
||||||
|
autofocus
|
||||||
|
@keyup.enter="updateConversation(cIdx)"
|
||||||
|
@click:append="updateConversation(cIdx)"
|
||||||
|
></v-text-field>
|
||||||
|
</v-list-item>
|
||||||
|
<v-hover
|
||||||
|
v-if="!editingConversation || editingConversation.id !== conversation.id"
|
||||||
|
v-slot="{ isHovering, props }"
|
||||||
|
>
|
||||||
|
<v-list-item
|
||||||
|
rounded="xl"
|
||||||
|
active-color="primary"
|
||||||
|
@click="openConversationMessages(conversation)"
|
||||||
|
v-bind="props"
|
||||||
|
>
|
||||||
|
<v-list-item-title>{{ conversation.topic }}</v-list-item-title>
|
||||||
|
<template v-slot:append>
|
||||||
|
<div
|
||||||
|
v-show="isHovering"
|
||||||
|
>
|
||||||
|
<v-btn
|
||||||
|
icon="edit"
|
||||||
|
size="small"
|
||||||
|
variant="text"
|
||||||
|
@click.stop="editConversation(cIdx)"
|
||||||
|
>
|
||||||
|
</v-btn>
|
||||||
|
<v-btn
|
||||||
|
icon="delete"
|
||||||
|
size="small"
|
||||||
|
variant="text"
|
||||||
|
:loading="deletingConversationIndex === cIdx"
|
||||||
|
@click.stop="deleteConversation(cIdx)"
|
||||||
|
>
|
||||||
|
</v-btn>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</v-list-item>
|
||||||
|
</v-hover>
|
||||||
|
</template>
|
||||||
|
</v-list>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-slot:append>
|
||||||
|
<div class="px-1">
|
||||||
|
<v-divider></v-divider>
|
||||||
|
<v-list>
|
||||||
|
|
||||||
|
<v-dialog
|
||||||
|
v-model="clearConfirmDialog"
|
||||||
|
persistent
|
||||||
|
>
|
||||||
|
<template v-slot:activator="{ props }">
|
||||||
|
<v-list-item
|
||||||
|
v-bind="props"
|
||||||
|
rounded="xl"
|
||||||
|
prepend-icon="delete_forever"
|
||||||
|
:title="$t('clearConversations')"
|
||||||
|
></v-list-item>
|
||||||
|
</template>
|
||||||
|
<v-card>
|
||||||
|
<v-card-title class="text-h5">
|
||||||
|
Are you sure you want to delete all conversations?
|
||||||
|
</v-card-title>
|
||||||
|
<v-card-text>This will be a permanent deletion and cannot be retrieved once deleted. Please proceed with caution.</v-card-text>
|
||||||
|
<v-card-actions>
|
||||||
|
<v-spacer></v-spacer>
|
||||||
|
<v-btn
|
||||||
|
color="green-darken-1"
|
||||||
|
variant="text"
|
||||||
|
@click="clearConfirmDialog = false"
|
||||||
|
class="text-none"
|
||||||
|
>
|
||||||
|
Cancel deletion
|
||||||
|
</v-btn>
|
||||||
|
<v-btn
|
||||||
|
color="green-darken-1"
|
||||||
|
variant="text"
|
||||||
|
@click="clearConversations"
|
||||||
|
class="text-none"
|
||||||
|
:loading="deletingConversations"
|
||||||
|
>
|
||||||
|
Confirm deletion
|
||||||
|
</v-btn>
|
||||||
|
</v-card-actions>
|
||||||
|
</v-card>
|
||||||
|
</v-dialog>
|
||||||
|
|
||||||
|
<ModelParameters/>
|
||||||
|
|
||||||
|
<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-md-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-btn
|
||||||
|
:title="$t('newConversation')"
|
||||||
|
icon="add"
|
||||||
|
@click="createNewConversion()"
|
||||||
|
></v-btn>
|
||||||
|
|
||||||
|
<!-- <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>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
v-if="$pwa?.offlineReady || $pwa?.needRefresh"
|
||||||
|
class="pwa-toast"
|
||||||
|
role="alert"
|
||||||
|
>
|
||||||
|
<div class="message">
|
||||||
|
<span v-if="$pwa.offlineReady">
|
||||||
|
App ready to work offline
|
||||||
|
</span>
|
||||||
|
<span v-else>
|
||||||
|
New content available, click on reload button to update.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
v-if="$pwa.needRefresh"
|
||||||
|
@click="$pwa.updateServiceWorker()"
|
||||||
|
>
|
||||||
|
Reload
|
||||||
|
</button>
|
||||||
|
<button @click="$pwa.cancelPrompt()">
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="$pwa?.showInstallPrompt && !$pwa?.offlineReady && !$pwa?.needRefresh"
|
||||||
|
class="pwa-toast"
|
||||||
|
role="alert"
|
||||||
|
>
|
||||||
|
<div class="message">
|
||||||
|
<span>
|
||||||
|
Install PWA
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button @click="$pwa.install()">
|
||||||
|
Install
|
||||||
|
</button>
|
||||||
|
<button @click="$pwa.cancelInstall()">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pwa-toast {
|
||||||
|
position: fixed;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
margin: 16px;
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid #8885;
|
||||||
|
border-radius: 4px;
|
||||||
|
z-index: 1;
|
||||||
|
text-align: left;
|
||||||
|
box-shadow: 3px 4px 5px 0 #8885;
|
||||||
|
}
|
||||||
|
.pwa-toast .message {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.pwa-toast button {
|
||||||
|
border: 1px solid #8885;
|
||||||
|
outline: none;
|
||||||
|
margin-right: 5px;
|
||||||
|
border-radius: 2px;
|
||||||
|
padding: 3px 10px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
7
layouts/vuetifyApp.vue
Normal file
7
layouts/vuetifyApp.vue
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<template>
|
||||||
|
<v-app
|
||||||
|
:theme="$colorMode.value"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</v-app>
|
||||||
|
</template>
|
||||||
21
nginx.conf
Normal file
21
nginx.conf
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,7 +11,9 @@ export default defineNuxtConfig({
|
|||||||
},
|
},
|
||||||
runtimeConfig: {
|
runtimeConfig: {
|
||||||
public: {
|
public: {
|
||||||
appName: appName
|
appName: appName,
|
||||||
|
typewriter: false,
|
||||||
|
typewriterDelay: 50,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
build: {
|
build: {
|
||||||
@@ -22,5 +24,70 @@ export default defineNuxtConfig({
|
|||||||
'material-design-icons-iconfont/dist/material-design-icons.css',
|
'material-design-icons-iconfont/dist/material-design-icons.css',
|
||||||
'highlight.js/styles/panda-syntax-dark.css',
|
'highlight.js/styles/panda-syntax-dark.css',
|
||||||
],
|
],
|
||||||
modules: ['@nuxtjs/color-mode']
|
modules: [
|
||||||
|
'@vite-pwa/nuxt',
|
||||||
|
'@nuxtjs/color-mode',
|
||||||
|
'@nuxtjs/i18n',
|
||||||
|
],
|
||||||
|
pwa: {
|
||||||
|
registerType: 'autoUpdate',
|
||||||
|
manifest: {
|
||||||
|
name: appName,
|
||||||
|
short_name: appName,
|
||||||
|
icons: [
|
||||||
|
{
|
||||||
|
src: 'icon-black.svg',
|
||||||
|
sizes: '900x900',
|
||||||
|
purpose: 'any maskable',
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
workbox: {
|
||||||
|
navigateFallback: '/',
|
||||||
|
globPatterns: ['**/*.{js,css,html,png,svg,ico}'],
|
||||||
|
},
|
||||||
|
client: {
|
||||||
|
installPrompt: true,
|
||||||
|
// you don't need to include this: only for testing purposes
|
||||||
|
// if enabling periodic sync for update use 1 hour or so (periodicSyncForUpdates: 3600)
|
||||||
|
periodicSyncForUpdates: 20,
|
||||||
|
},
|
||||||
|
devOptions: {
|
||||||
|
enabled: true,
|
||||||
|
type: 'module',
|
||||||
|
}
|
||||||
|
},
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
14
package.json
14
package.json
@@ -9,15 +9,19 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@nuxtjs/color-mode": "^3.2.0",
|
"@nuxtjs/color-mode": "^3.2.0",
|
||||||
|
"@nuxtjs/i18n": "^8.0.0-beta.9",
|
||||||
"material-design-icons-iconfont": "^6.7.0",
|
"material-design-icons-iconfont": "^6.7.0",
|
||||||
"nuxt": "^3.1.2"
|
"nuxt": "^3.2.0"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@keyv/sqlite": "^3.6.4",
|
|
||||||
"@microsoft/fetch-event-source": "^2.0.1",
|
"@microsoft/fetch-event-source": "^2.0.1",
|
||||||
"@waylaidwanderer/chatgpt-api": "^1.12.2",
|
"@vite-pwa/nuxt": "^0.0.7",
|
||||||
|
"copy-to-clipboard": "^3.3.3",
|
||||||
"highlight.js": "^11.7.0",
|
"highlight.js": "^11.7.0",
|
||||||
"marked": "^4.2.12",
|
"is-mobile": "^3.1.1",
|
||||||
|
"markdown-it": "^13.0.1",
|
||||||
|
"nanoid": "^4.0.1",
|
||||||
"vuetify": "^3.0.6"
|
"vuetify": "^3.0.6"
|
||||||
}
|
},
|
||||||
|
"license": "MIT"
|
||||||
}
|
}
|
||||||
|
|||||||
81
pages/account/onboarding.vue
Normal file
81
pages/account/onboarding.vue
Normal 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>
|
||||||
118
pages/account/signin.vue
Normal file
118
pages/account/signin.vue
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
<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"
|
||||||
|
clearable
|
||||||
|
></v-text-field>
|
||||||
|
<v-text-field
|
||||||
|
v-model="formData.password"
|
||||||
|
:rules="formRules.password"
|
||||||
|
label="Password"
|
||||||
|
variant="underlined"
|
||||||
|
@keyup.enter="submit"
|
||||||
|
clearable
|
||||||
|
:type="passwordInputType"
|
||||||
|
:append-inner-icon="passwordInputType === 'password' ? 'visibility' : 'visibility_off'"
|
||||||
|
@click:append-inner="passwordInputType = passwordInputType === 'password' ? 'text' : 'password'"
|
||||||
|
></v-text-field>
|
||||||
|
|
||||||
|
</v-form>
|
||||||
|
|
||||||
|
<div v-if="errorMsg" class="text-red">{{ errorMsg }}</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="mt-5 d-flex justify-space-between"
|
||||||
|
>
|
||||||
|
<v-btn
|
||||||
|
@click="navigateTo('/account/signup')"
|
||||||
|
variant="text"
|
||||||
|
color="primary"
|
||||||
|
>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 passwordInputType = ref('password')
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
errorMsg.value = null
|
||||||
|
const { valid } = await signInForm.value.validate()
|
||||||
|
if (valid) {
|
||||||
|
submitting.value = true
|
||||||
|
const { data, error } = await useFetch('/api/account/login/', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(formData.value)
|
||||||
|
})
|
||||||
|
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>
|
||||||
176
pages/account/signup.vue
Normal file
176
pages/account/signup.vue
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
<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 {
|
||||||
|
if (error.value.data.detail) {
|
||||||
|
errorMsg.value = error.value.data.detail
|
||||||
|
} 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>
|
||||||
100
pages/account/verify-email.vue
Normal file
100
pages/account/verify-email.vue
Normal 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>
|
||||||
192
pages/index.vue
192
pages/index.vue
@@ -1,98 +1,147 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { fetchEventSource } from '@microsoft/fetch-event-source'
|
import Prompt from "~/components/Prompt.vue";
|
||||||
|
|
||||||
|
definePageMeta({
|
||||||
|
middleware: ["auth"]
|
||||||
|
})
|
||||||
|
import {EventStreamContentType, fetchEventSource} from '@microsoft/fetch-event-source'
|
||||||
|
import { nextTick } from 'vue'
|
||||||
|
import MessageActions from "~/components/MessageActions.vue";
|
||||||
|
|
||||||
|
const { $i18n, $auth } = useNuxtApp()
|
||||||
const runtimeConfig = useRuntimeConfig()
|
const runtimeConfig = useRuntimeConfig()
|
||||||
const currentModel = useCurrentModel()
|
const currentModel = useCurrentModel()
|
||||||
const openaiApiKey = useApiKey()
|
const openaiApiKey = useApiKey()
|
||||||
const fetchingResponse = ref(false)
|
const fetchingResponse = ref(false)
|
||||||
|
const messageQueue = []
|
||||||
|
let isProcessingQueue = false
|
||||||
|
|
||||||
|
const processMessageQueue = () => {
|
||||||
|
if (isProcessingQueue || messageQueue.length === 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!currentConversation.value.messages[currentConversation.value.messages.length - 1].is_bot) {
|
||||||
|
currentConversation.value.messages.push({id: null, is_bot: true, message: ''})
|
||||||
|
}
|
||||||
|
isProcessingQueue = true
|
||||||
|
const nextMessage = messageQueue.shift()
|
||||||
|
if (runtimeConfig.public.typewriter) {
|
||||||
|
let wordIndex = 0;
|
||||||
|
const intervalId = setInterval(() => {
|
||||||
|
currentConversation.value.messages[currentConversation.value.messages.length - 1].message += nextMessage[wordIndex]
|
||||||
|
wordIndex++
|
||||||
|
if (wordIndex === nextMessage.length) {
|
||||||
|
clearInterval(intervalId)
|
||||||
|
isProcessingQueue = false
|
||||||
|
processMessageQueue()
|
||||||
|
}
|
||||||
|
}, runtimeConfig.public.typewriterDelay)
|
||||||
|
} else {
|
||||||
|
currentConversation.value.messages[currentConversation.value.messages.length - 1].message += nextMessage
|
||||||
|
isProcessingQueue = false
|
||||||
|
processMessageQueue()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let ctrl
|
||||||
|
const abortFetch = () => {
|
||||||
|
if (ctrl) {
|
||||||
|
ctrl.abort()
|
||||||
|
}
|
||||||
|
fetchingResponse.value = false
|
||||||
|
}
|
||||||
const fetchReply = async (message, parentMessageId) => {
|
const fetchReply = async (message, parentMessageId) => {
|
||||||
const ctrl = new AbortController()
|
ctrl = new AbortController()
|
||||||
try {
|
|
||||||
await fetchEventSource('/api/conversation', {
|
const data = Object.assign({}, currentModel.value, {
|
||||||
signal: ctrl.signal,
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
model: currentModel.value,
|
|
||||||
openaiApiKey: openaiApiKey.value,
|
openaiApiKey: openaiApiKey.value,
|
||||||
message: message,
|
message: message,
|
||||||
parentMessageId: parentMessageId,
|
parentMessageId: parentMessageId,
|
||||||
conversationId: currentConversation.value.id
|
conversationId: currentConversation.value.id
|
||||||
}),
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fetchEventSource('/api/conversation/', {
|
||||||
|
signal: ctrl.signal,
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'accept': 'application/json',
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(data),
|
||||||
onopen(response) {
|
onopen(response) {
|
||||||
if (response.status === 200) {
|
if (response.ok && response.headers.get('content-type') === EventStreamContentType) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
throw new Error(`Failed to send message. HTTP ${response.status} - ${response.statusText}`);
|
throw new Error(`Failed to send message. HTTP ${response.status} - ${response.statusText}`);
|
||||||
},
|
},
|
||||||
onclose() {
|
onclose() {
|
||||||
|
if (ctrl.signal.aborted === true) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
throw new Error(`Failed to send message. Server closed the connection unexpectedly.`);
|
throw new Error(`Failed to send message. Server closed the connection unexpectedly.`);
|
||||||
},
|
},
|
||||||
onerror(err) {
|
onerror(err) {
|
||||||
throw err;
|
throw err;
|
||||||
},
|
},
|
||||||
onmessage(message) {
|
async onmessage(message) {
|
||||||
if (message.event === 'error') {
|
// console.log(message)
|
||||||
throw new Error(JSON.parse(message.data).error);
|
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) {
|
if (currentConversation.value.id === null) {
|
||||||
currentConversation.value.id = data.conversationId
|
currentConversation.value.id = data.conversationId
|
||||||
|
genTitle(currentConversation.value.id)
|
||||||
}
|
}
|
||||||
currentConversation.value.messages[currentConversation.value.messages.length - 1].id = data.messageId
|
currentConversation.value.messages[currentConversation.value.messages.length - 1].id = data.messageId
|
||||||
ctrl.abort();
|
abortFetch()
|
||||||
fetchingResponse.value = false
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (currentConversation.value.messages[currentConversation.value.messages.length - 1].from === 'ai') {
|
|
||||||
currentConversation.value.messages[currentConversation.value.messages.length - 1].message += data
|
messageQueue.push(data.content)
|
||||||
} else {
|
processMessageQueue()
|
||||||
currentConversation.value.messages.push({id: null, from: 'ai', message: data})
|
|
||||||
}
|
|
||||||
scrollChatWindow()
|
scrollChatWindow()
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
ctrl.abort()
|
console.log(err)
|
||||||
|
abortFetch()
|
||||||
showSnackbar(err.message)
|
showSnackbar(err.message)
|
||||||
fetchingResponse.value = false
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultConversation = ref({
|
const currentConversation = useConversion()
|
||||||
id: null,
|
|
||||||
messages: []
|
|
||||||
})
|
|
||||||
const currentConversation = ref({})
|
|
||||||
|
|
||||||
const grab = ref(null)
|
const grab = ref(null)
|
||||||
const scrollChatWindow = () => {
|
const scrollChatWindow = () => {
|
||||||
|
if (grab.value === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
grab.value.scrollIntoView({behavior: 'smooth'})
|
grab.value.scrollIntoView({behavior: 'smooth'})
|
||||||
}
|
}
|
||||||
|
|
||||||
const createNewConversation = () => {
|
|
||||||
currentConversation.value = Object.assign(defaultConversation.value, {
|
|
||||||
})
|
|
||||||
}
|
|
||||||
const send = (message) => {
|
const send = (message) => {
|
||||||
fetchingResponse.value = true
|
fetchingResponse.value = true
|
||||||
let parentMessageId = null
|
let parentMessageId = null
|
||||||
if (currentConversation.value.messages.length > 0) {
|
if (currentConversation.value.messages.length > 0) {
|
||||||
const lastMessage = currentConversation.value.messages[currentConversation.value.messages.length - 1]
|
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
|
parentMessageId = lastMessage.id
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
currentConversation.value.messages.push({from: 'me', parentMessageId: parentMessageId, message: message})
|
currentConversation.value.messages.push({parentMessageId: parentMessageId, message: message})
|
||||||
fetchReply(message, parentMessageId)
|
fetchReply(message, parentMessageId)
|
||||||
scrollChatWindow()
|
scrollChatWindow()
|
||||||
}
|
}
|
||||||
const stop = () => {
|
const stop = () => {
|
||||||
ctrl.abort();
|
abortFetch()
|
||||||
fetchingResponse.value = false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const snackbar = ref(false)
|
const snackbar = ref(false)
|
||||||
@@ -102,30 +151,62 @@ const showSnackbar = (text) => {
|
|||||||
snackbar.value = true
|
snackbar.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
createNewConversation()
|
const editor = ref(null)
|
||||||
|
const usePrompt = (prompt) => {
|
||||||
|
editor.value.usePrompt(prompt)
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteMessage = (index) => {
|
||||||
|
currentConversation.value.messages.splice(index, 1)
|
||||||
|
}
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div ref="chatWindow">
|
<div
|
||||||
<v-card
|
v-if="currentConversation.messages.length > 0"
|
||||||
rounded="0"
|
ref="chatWindow"
|
||||||
elevation="0"
|
|
||||||
v-for="(conversation, index) in currentConversation.messages"
|
|
||||||
:key="index"
|
|
||||||
:variant="conversation.from === 'ai' ? 'tonal' : ''"
|
|
||||||
>
|
>
|
||||||
<v-container>
|
<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 align-center"
|
||||||
|
:class="message.is_bot ? 'justify-start' : 'justify-end'"
|
||||||
|
>
|
||||||
|
<MessageActions
|
||||||
|
v-if="!message.is_bot"
|
||||||
|
:message="message"
|
||||||
|
:message-index="index"
|
||||||
|
/>
|
||||||
|
<v-card
|
||||||
|
:color="message.is_bot ? '' : 'primary'"
|
||||||
|
rounded="lg"
|
||||||
|
elevation="2"
|
||||||
|
>
|
||||||
<v-card-text>
|
<v-card-text>
|
||||||
<MsgContent :content="conversation.message" />
|
<MsgContent :content="message.message" />
|
||||||
</v-card-text>
|
</v-card-text>
|
||||||
</v-container>
|
|
||||||
<v-divider></v-divider>
|
|
||||||
</v-card>
|
</v-card>
|
||||||
<div ref="grab" class="w-100" style="height: 150px;"></div>
|
<MessageActions
|
||||||
|
v-if="message.is_bot"
|
||||||
|
:message="message"
|
||||||
|
:message-index="index"
|
||||||
|
/>
|
||||||
</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">
|
<v-footer app class="d-flex flex-column">
|
||||||
<div class="px-md-16 w-100 d-flex align-center">
|
<div class="px-md-16 w-100 d-flex align-center">
|
||||||
|
<Prompt v-show="!fetchingResponse" :use-prompt="usePrompt" />
|
||||||
<v-btn
|
<v-btn
|
||||||
v-show="fetchingResponse"
|
v-show="fetchingResponse"
|
||||||
icon="close"
|
icon="close"
|
||||||
@@ -133,16 +214,17 @@ createNewConversation()
|
|||||||
class="mr-3"
|
class="mr-3"
|
||||||
@click="stop"
|
@click="stop"
|
||||||
></v-btn>
|
></v-btn>
|
||||||
<MsgEditor :send-message="send" :disabled="fetchingResponse" :loading="fetchingResponse" />
|
<MsgEditor ref="editor" :send-message="send" :disabled="fetchingResponse" :loading="fetchingResponse" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="px-4 py-2 text-disabled text-caption font-weight-light text-center w-100">
|
<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>
|
</div>
|
||||||
</v-footer>
|
</v-footer>
|
||||||
<v-snackbar
|
<v-snackbar
|
||||||
v-model="snackbar"
|
v-model="snackbar"
|
||||||
multi-line
|
multi-line
|
||||||
|
location="top"
|
||||||
>
|
>
|
||||||
{{ snackbarText }}
|
{{ snackbarText }}
|
||||||
|
|
||||||
|
|||||||
71
plugins/auth.js
Normal file
71
plugins/auth.js
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -1,11 +1,13 @@
|
|||||||
import { createVuetify } from 'vuetify'
|
import { createVuetify } from 'vuetify'
|
||||||
import { aliases, md } from 'vuetify/iconsets/md'
|
import { aliases, md } from 'vuetify/iconsets/md'
|
||||||
import * as components from 'vuetify/components'
|
import * as components from 'vuetify/components'
|
||||||
|
import { md3 } from 'vuetify/blueprints'
|
||||||
// import * as directives from 'vuetify/directives'
|
// import * as directives from 'vuetify/directives'
|
||||||
|
|
||||||
export default defineNuxtPlugin(nuxtApp => {
|
export default defineNuxtPlugin(nuxtApp => {
|
||||||
const vuetify = createVuetify({
|
const vuetify = createVuetify({
|
||||||
ssr: true,
|
ssr: true,
|
||||||
|
blueprint: md3,
|
||||||
icons: {
|
icons: {
|
||||||
defaultSet: 'md',
|
defaultSet: 'md',
|
||||||
aliases,
|
aliases,
|
||||||
|
|||||||
3
public/icon-black.svg
Normal file
3
public/icon-black.svg
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<svg width="900" height="900" viewBox="0 0 900 900" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M504.908 750H839.476C850.103 750.001 860.542 747.229 869.745 741.963C878.948 736.696 886.589 729.121 891.9 719.999C897.211 710.876 900.005 700.529 900 689.997C899.995 679.465 897.193 669.12 891.873 660.002L667.187 274.289C661.876 265.169 654.237 257.595 645.036 252.329C635.835 247.064 625.398 244.291 614.773 244.291C604.149 244.291 593.711 247.064 584.511 252.329C575.31 257.595 567.67 265.169 562.36 274.289L504.908 372.979L392.581 179.993C387.266 170.874 379.623 163.301 370.42 158.036C361.216 152.772 350.777 150 340.151 150C329.525 150 319.086 152.772 309.883 158.036C300.679 163.301 293.036 170.874 287.721 179.993L8.12649 660.002C2.80743 669.12 0.00462935 679.465 5.72978e-06 689.997C-0.00461789 700.529 2.78909 710.876 8.10015 719.999C13.4112 729.121 21.0523 736.696 30.255 741.963C39.4576 747.229 49.8973 750.001 60.524 750H270.538C353.748 750 415.112 713.775 457.336 643.101L559.849 467.145L614.757 372.979L779.547 655.834H559.849L504.908 750ZM267.114 655.737L120.551 655.704L340.249 278.586L449.87 467.145L376.474 593.175C348.433 639.03 316.577 655.737 267.114 655.737Z" fill="#0C0C0D"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.2 KiB |
@@ -1,108 +0,0 @@
|
|||||||
import ChatGPTClient from '@waylaidwanderer/chatgpt-api'
|
|
||||||
import { PassThrough } from 'node:stream'
|
|
||||||
|
|
||||||
const serializeSSEEvent = (chunk) => {
|
|
||||||
let payload = "";
|
|
||||||
if (chunk.id) {
|
|
||||||
payload += `id: ${chunk.id}\n`;
|
|
||||||
}
|
|
||||||
if (chunk.event) {
|
|
||||||
payload += `event: ${chunk.event}\n`;
|
|
||||||
}
|
|
||||||
if (chunk.data) {
|
|
||||||
payload += `data: ${chunk.data}\n`;
|
|
||||||
}
|
|
||||||
if (chunk.retry) {
|
|
||||||
payload += `retry: ${chunk.retry}\n`;
|
|
||||||
}
|
|
||||||
if (!payload) {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
payload += "\n";
|
|
||||||
return payload;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
|
||||||
const body = await readBody(event)
|
|
||||||
const conversationId = body.conversationId ? body.conversationId.toString() : undefined
|
|
||||||
const parentMessageId = body.parentMessageId ? body.parentMessageId.toString() : undefined
|
|
||||||
const tunnel = new PassThrough()
|
|
||||||
const writeToTunnel = (data) => {
|
|
||||||
tunnel.write(serializeSSEEvent(data))
|
|
||||||
}
|
|
||||||
setResponseHeaders(event, {
|
|
||||||
'Content-Type': 'text/event-stream',
|
|
||||||
'Cache-Control': 'no-cache',
|
|
||||||
'Connection': 'keep-alive'
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!body.openaiApiKey) {
|
|
||||||
writeToTunnel({
|
|
||||||
event: 'error',
|
|
||||||
data: JSON.stringify({
|
|
||||||
code: 503,
|
|
||||||
error: 'You haven\'t set the api key of openai',
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
return sendStream(event, tunnel)
|
|
||||||
}
|
|
||||||
|
|
||||||
const clientOptions = {
|
|
||||||
// (Optional) Parameters as described in https://platform.openai.com/docs/api-reference/completions
|
|
||||||
modelOptions: {
|
|
||||||
// The model is set to text-chat-davinci-002-20221122 by default, but you can override
|
|
||||||
// it and any other parameters here
|
|
||||||
model: body.model,
|
|
||||||
},
|
|
||||||
// (Optional) Set custom instructions instead of "You are ChatGPT...".
|
|
||||||
// promptPrefix: 'You are Bob, a cowboy in Western times...',
|
|
||||||
// (Optional) Set a custom name for the user
|
|
||||||
// userLabel: 'User',
|
|
||||||
// (Optional) Set a custom name for ChatGPT
|
|
||||||
// chatGptLabel: 'ChatGPT',
|
|
||||||
// (Optional) Set to true to enable `console.debug()` logging
|
|
||||||
debug: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
const cacheOptions = {
|
|
||||||
// Options for the Keyv cache, see https://www.npmjs.com/package/keyv
|
|
||||||
// This is used for storing conversations, and supports additional drivers (conversations are stored in memory by default)
|
|
||||||
// For example, to use a JSON file (`npm i keyv-file`) as a database:
|
|
||||||
// store: new KeyvFile({ filename: 'cache.json' }),
|
|
||||||
uri: 'sqlite://database.sqlite'
|
|
||||||
};
|
|
||||||
|
|
||||||
const chatGptClient = new ChatGPTClient(body.openaiApiKey, clientOptions, cacheOptions);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await chatGptClient.sendMessage(body.message, {
|
|
||||||
conversationId,
|
|
||||||
parentMessageId,
|
|
||||||
onProgress: (token) => {
|
|
||||||
// console.log(token)
|
|
||||||
writeToTunnel({ data: JSON.stringify({
|
|
||||||
type: 'token',
|
|
||||||
data: token
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
});
|
|
||||||
writeToTunnel({ data: JSON.stringify({
|
|
||||||
type: 'done',
|
|
||||||
data: response
|
|
||||||
}) })
|
|
||||||
console.log(response)
|
|
||||||
} catch (e) {
|
|
||||||
const code = e?.json?.data?.code || 503;
|
|
||||||
const message = e?.json?.error?.message || 'There was an error communicating with ChatGPT.';
|
|
||||||
writeToTunnel({
|
|
||||||
event: 'error',
|
|
||||||
data: JSON.stringify({
|
|
||||||
code,
|
|
||||||
error: message,
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return sendStream(event, tunnel)
|
|
||||||
})
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
import {getSetting, setSetting} from "~/utils/keyv";
|
|
||||||
import {apiError, apiSuccess} from "~/utils/api";
|
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
|
||||||
const runtimeConfig = useRuntimeConfig()
|
|
||||||
const method = getMethod(event)
|
|
||||||
if (method === 'GET') {
|
|
||||||
const query = getQuery(event)
|
|
||||||
let value = await getSetting(query.key)
|
|
||||||
if (!value && query.key === 'modelName') {
|
|
||||||
value = runtimeConfig.openaiModelName
|
|
||||||
}
|
|
||||||
return apiSuccess(value)
|
|
||||||
} else if (method === 'POST') {
|
|
||||||
const body = await readBody(event)
|
|
||||||
await setSetting(body.key, body.value)
|
|
||||||
return apiSuccess()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
16
utils/api.js
16
utils/api.js
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,15 @@
|
|||||||
|
|
||||||
export const STORAGE_KEY = {
|
export const STORAGE_KEY = {
|
||||||
OPENAI_MODELS: 'openai_models',
|
MODELS: 'models',
|
||||||
CURRENT_OPENAI_MODEL: 'current_openai_model',
|
CURRENT_MODEL: 'current_model',
|
||||||
OPENAI_API_KEY: 'openai_api_key',
|
OPENAI_API_KEY: 'openai_api_key',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_MODEL = {
|
||||||
|
name: 'gpt-3.5-turbo',
|
||||||
|
frequency_penalty: 0.0,
|
||||||
|
presence_penalty: 0.0,
|
||||||
|
max_tokens: 1000,
|
||||||
|
temperature: 0.7,
|
||||||
|
top_p: 1.0
|
||||||
|
}
|
||||||
53
utils/helper.js
Normal file
53
utils/helper.js
Normal 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
|
||||||
|
}
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
import Keyv from 'keyv'
|
|
||||||
import KeyvSqlite from "@keyv/sqlite";
|
|
||||||
|
|
||||||
const sqlite = new KeyvSqlite()
|
|
||||||
|
|
||||||
const cacheOptions = {
|
|
||||||
namespace: 'settings',
|
|
||||||
uri: 'sqlite://database.sqlite',
|
|
||||||
}
|
|
||||||
const cache = new Keyv(cacheOptions);
|
|
||||||
|
|
||||||
export const getSetting = async (key) => {
|
|
||||||
return await cache.get(key)
|
|
||||||
}
|
|
||||||
|
|
||||||
export const setSetting = async (key, value) => {
|
|
||||||
return await cache.set(key, value)
|
|
||||||
}
|
|
||||||
@@ -11,32 +11,28 @@ const set = (key, val) => {
|
|||||||
localStorage.setItem(key, JSON.stringify(val))
|
localStorage.setItem(key, JSON.stringify(val))
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_OPENAI_MODEL = 'text-davinci-003'
|
|
||||||
|
|
||||||
export const setModels = (val) => {
|
export const setModels = (val) => {
|
||||||
const models = useModels()
|
const models = useModels()
|
||||||
set(STORAGE_KEY.OPENAI_MODELS, val)
|
set(STORAGE_KEY.MODELS, val)
|
||||||
models.value = val
|
models.value = val
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getStoredModels = () => {
|
export const getStoredModels = () => {
|
||||||
let models = get(STORAGE_KEY.OPENAI_MODELS)
|
let models = get(STORAGE_KEY.MODELS)
|
||||||
if (!models) {
|
if (!models) {
|
||||||
models = [DEFAULT_OPENAI_MODEL]
|
models = [DEFAULT_MODEL]
|
||||||
}
|
}
|
||||||
return models
|
return models
|
||||||
}
|
}
|
||||||
|
|
||||||
export const setCurrentModel = (val) => {
|
export const saveCurrentModel = (val) => {
|
||||||
const model = useCurrentModel()
|
set(STORAGE_KEY.CURRENT_MODEL, val)
|
||||||
set(STORAGE_KEY.CURRENT_OPENAI_MODEL, val)
|
|
||||||
model.value = val
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getCurrentModel = () => {
|
export const getCurrentModel = () => {
|
||||||
let model = get(STORAGE_KEY.CURRENT_OPENAI_MODEL)
|
let model = get(STORAGE_KEY.CURRENT_MODEL)
|
||||||
if (!model) {
|
if (!model) {
|
||||||
model = DEFAULT_OPENAI_MODEL
|
model = DEFAULT_MODEL
|
||||||
}
|
}
|
||||||
return model
|
return model
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user