Compare commits

...

63 Commits

Author SHA1 Message Date
Rafi
3f3ab8c33b Fix the issue of unable to copy code blocks in new messages. 2023-04-06 10:17:15 +08:00
Rafi
6522536291 fix some known bugs 2023-04-06 09:56:26 +08:00
Rafi
2bca5a032c fix hydration text missing 2023-04-05 23:27:44 +08:00
Rafi
53460bd891 set default footer width 2023-04-05 23:20:31 +08:00
Rafi
fb9e8b8c7d api proxy 2023-04-05 23:17:14 +08:00
Rafi
21dc2b9236 fix Dockerfile 2023-04-04 19:43:20 +08:00
Rafi
1a6bf1d239 Improve the conversation process 2023-04-04 19:23:57 +08:00
Rafi
3e3283029d Improve the conversation process 2023-04-04 19:16:07 +08:00
Rafi
16c9b0e230 ... 2023-04-03 23:37:50 +08:00
Rafi
836df995d0 Improve login and authentication methods to adapt to the SSR mode. 2023-04-03 22:04:52 +08:00
Rafi
5b9d52b177 support ssr 2023-04-03 18:19:39 +08:00
Rafi
deb627a9ab Convert the MsgEditor component to a composite one 2023-04-02 15:43:31 +08:00
Rafi
70efc09dae Optimize the layout of message content. 2023-04-02 15:10:22 +08:00
Rafi
8ff914582a Fix the bug of conversation title 2023-04-01 21:54:15 +08:00
Rafi
f20a3562f3 Replace blank items in the conversation list with the default title 2023-04-01 21:22:44 +08:00
Rafi
4a1adf6d00 Add reset password function and display current user on the page. 2023-04-01 11:29:24 +08:00
Rafi
ddce1c9721 update readme 2023-03-31 17:37:10 +08:00
Rafi
f67ed7621c Multiple improvements for conversation 2023-03-30 21:45:23 +08:00
Rafi
97649e4bee pass 2023-03-30 18:12:55 +08:00
Rafi
1082da050b rm @vite-pwa/nuxt 2023-03-30 10:37:55 +08:00
Wong Saang
d89d1e288d Merge pull request #97 from erritis/main
Add title for prompt
2023-03-30 09:31:37 +08:00
Sergey Shekhovtsov
cd89d11d0b Add docker compose file for development
Added docker compose file for development

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

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

Not all interface elements had the ability to add translation
2023-03-28 03:03:23 +03:00
Rafi
d75413cc49 update readme 2023-03-27 22:26:47 +08:00
Rafi
8175f199d2 Support GPT-4 2023-03-27 22:17:19 +08:00
Wong Saang
f8c2f396c1 Merge pull request #70 from Paramon/main
#50 add platform to docker-compose
2023-03-25 21:03:50 +08:00
Andrii Paramonov
8217647df8 #50 add platform 2023-03-24 16:44:00 +02:00
Rafi
288c9eeeca Change the functionality of custom API key to be controlled by the admin panel to determine whether it is enabled or not. 2023-03-24 15:43:01 +08:00
Rafi
4d09ff7c8a Added env var NUXT_PUBLIC_CUSTOM_API_KEY to docs 2023-03-24 14:32:38 +08:00
Rafi
5fa059017c Support controlling whether to enable the API Key setting module through environment variables. 2023-03-24 14:22:45 +08:00
Rafi
323f10844b Modify the placeholder of the default prompt for web search to solve the problem of not providing web search results to ChatGPT 2023-03-24 11:22:51 +08:00
Rafi
ee035390db add default prompt to web search 2023-03-23 18:48:08 +08:00
Wong Saang
be743bf799 Update README.md 2023-03-23 17:13:34 +08:00
Wong Saang
a59f84f2bf Update README.md 2023-03-23 17:12:59 +08:00
Rafi
ed0cf2997d update readme 2023-03-23 17:07:49 +08:00
Rafi
7f00c74097 Added wsgi-server environment variable EMAIL_FROM to docker-compose.yml and readme 2023-03-23 15:31:26 +08:00
Rafi
f007417fa4 add update info to readme 2023-03-23 12:53:08 +08:00
Rafi
27c5e2a3ac Get settings from backend, added web search functionality 2023-03-23 11:45:56 +08:00
Rafi
e90dc0c12b web_search toolbar 2023-03-22 23:29:58 +08:00
Rafi
837fd8c9ff update readme 2023-03-22 17:26:22 +08:00
Rafi
ce0b1004f3 Remove the parent_message_id constraint 2023-03-22 16:17:46 +08:00
Rafi
1ff1c46e37 Fix the bug of being unable to delete messages. 2023-03-22 15:55:06 +08:00
Rafi
afa3e499dc add DEBUT_PWA env variable 2023-03-22 14:12:49 +08:00
Rafi
70ce5746bc Merge remote-tracking branch 'origin/main' into main
# Conflicts:
#	nuxt.config.ts
#	yarn.lock
2023-03-22 13:50:53 +08:00
Rafi
35d4292d29 Import the @kevinmarrec/nuxt-pwa module to fix the related bugs of PWA feature. 2023-03-21 22:13:02 +08:00
Rafi
8bbc44e7bf update nuxt.config.ts 2023-03-21 18:48:35 +08:00
Rafi
3dcb4be6e4 add robots.txt 2023-03-21 18:06:44 +08:00
Rafi
83f8072625 mv @vite-pwa/nuxt to devDependencies 2023-03-21 13:46:02 +08:00
Rafi
3992121b71 update: docker-compose.yml 2023-03-21 10:20:08 +08:00
Rafi
d08806f0c9 update readme 2023-03-20 22:15:13 +08:00
Rafi
85ac73efcc Add email verification requirement judgment after completing registration 2023-03-20 22:03:53 +08:00
Rafi
7cc5a6b347 Fix: the language settings dialog not displaying the close button. 2023-03-20 20:13:23 +08:00
Rafi
983e4d436d update: deployment.sh 2023-03-20 12:54:11 +08:00
Rafi
727826f1b1 Added a Sign-out button 2023-03-19 14:26:46 +08:00
Rafi
386659109c Added a new message action: delete 2023-03-19 13:49:12 +08:00
Rafi
bd9e8bf45e Optimize the editor and enhance the user experience. 2023-03-19 13:39:20 +08:00
Rafi
4e40530a8c Added a new message action: edit 2023-03-19 13:13:27 +08:00
Rafi
ea69a350f4 add environment variable NUXT_DEV_SERVER 2023-03-19 12:53:44 +08:00
Rafi
18a4251714 feat: Message actions 2023-03-17 18:27:07 +08:00
42 changed files with 2665 additions and 3987 deletions

View File

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

View File

@@ -1,14 +1,29 @@
<p align="center"> <div align="center">
<img alt="demo" src="./demos/demo.gif?v=1"> <h1>ChatGPT UI</h1>
</p> </div>
[English](./README.md) | [中文](./docs/zh/README.md) [English](./README.md) | [中文](./docs/zh/README.md)
# ChatGPT UI
A ChatGPT web client that supports multiple users, multiple database connections for persistent data storage, supports i18n. Provides Docker images and quick deployment scripts. A ChatGPT web client that supports multiple users, multiple database connections for persistent data storage, supports i18n. Provides Docker images and quick deployment scripts.
https://user-images.githubusercontent.com/46235412/227156264-ca17ab17-999b-414f-ab06-3f75b5235bfe.mp4
## 📢Updates ## 📢Updates
<details open>
<summary><strong>2023-03-27</strong></summary>
🚀 Support gpt-4 model. You can select the model in the "Model Parameters" of the front-end.
The GPT-4 model requires whitelist access from OpenAI.
</details>
<details open>
<summary><strong>2023-03-23</strong></summary>
Added web search capability to generate more relevant and up-to-date answers from ChatGPT!
This feature is off by default, you can turn it on in `Chat->Settings` in the admin panel, there is a record `open_web_search` in Settings, set its value to True.
</details>
<details open> <details open>
<summary><strong>2023-03-15</strong></summary> <summary><strong>2023-03-15</strong></summary>
@@ -17,17 +32,7 @@ Add "open_registration" setting option in the admin panel to control whether use
</details> </details>
<details open> <details>
<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> <summary><strong>2023-03-04</strong></summary>
**Update to the latest official chat model** `gpt-3.5-turbo` **Update to the latest official chat model** `gpt-3.5-turbo`
@@ -90,9 +95,6 @@ services:
image: wongsaang/chatgpt-ui-client:latest image: wongsaang/chatgpt-ui-client:latest
environment: environment:
- SERVER_DOMAIN=http://backend-web-server - 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: depends_on:
- backend-web-server - backend-web-server
ports: ports:
@@ -103,17 +105,20 @@ services:
image: wongsaang/chatgpt-ui-wsgi-server:latest image: wongsaang/chatgpt-ui-wsgi-server:latest
environment: environment:
- APP_DOMAIN=${APP_DOMAIN:-localhost:9000} # CSRF whitelistAdd the address of your chatgpt-ui-web-server here, default is localhost:9000 - APP_DOMAIN=${APP_DOMAIN:-localhost:9000} # CSRF whitelistAdd the address of your chatgpt-ui-web-server here, default is localhost:9000
- SERVER_WORKERS=3 # Number of gunicorn workers, default is 3
#- 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. #- 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 #- OPENAI_API_PROXY=https://openai.proxy.com/v1 # Proxy for https://api.openai.com/v1
- DJANGO_SUPERUSER_USERNAME=admin # default superuser name - DJANGO_SUPERUSER_USERNAME=admin # default superuser name
- DJANGO_SUPERUSER_PASSWORD=password # default superuser password - DJANGO_SUPERUSER_PASSWORD=password # default superuser password
- DJANGO_SUPERUSER_EMAIL=admin@example.com # default superuser email - DJANGO_SUPERUSER_EMAIL=admin@example.com # default superuser email
- ACCOUNT_EMAIL_VERIFICATION=none # Determines the e-mail verification method during signup choose one of "none", "optional", or "mandatory". Default is "optional". If you don't need to verify the email, you can set it to "none".
# If you want to use the email verification function, you need to configure the following parameters # If you want to use the email verification function, you need to configure the following parameters
# - EMAIL_HOST=SMTP server address # - EMAIL_HOST=SMTP server address
# - EMAIL_PORT=SMTP server port # - EMAIL_PORT=SMTP server port
# - EMAIL_HOST_USER= # - EMAIL_HOST_USER=
# - EMAIL_HOST_PASSWORD= # - EMAIL_HOST_PASSWORD=
# - EMAIL_USE_TLS=True # - EMAIL_USE_TLS=True
# - EMAIL_FROM=no-reply@example.com #Default sender email address
ports: ports:
- '8000:8000' - '8000:8000'
networks: networks:
@@ -155,6 +160,16 @@ Before you can start chatting, you need to add an OpenAI API key. In the Setting
Now you can access the web client at `http(s)://your.domain` or `http://123.123.123.123` to start chatting. Now you can access the web client at `http(s)://your.domain` or `http://123.123.123.123` to start chatting.
## Donation
> If it is helpful to you, it is also helping me.
If you want to support me, Buy me a coffee ❤️ [https://www.buymeacoffee.com/WongSaang](https://www.buymeacoffee.com/WongSaang)
<p align="center">
<img height="150" src="https://github.com/WongSaang/chatgpt-ui/blob/main/demos/bmc_qr.png?raw=true"/>
</p>
## Development ## Development
### Setup ### Setup
@@ -180,4 +195,4 @@ Build the application for production:
```bash ```bash
yarn build yarn build
``` ```

View File

@@ -1,9 +1,6 @@
<template> <template>
<div> <NuxtLayout>
<VitePwaManifest />
<NuxtLoadingIndicator /> <NuxtLoadingIndicator />
<NuxtLayout> <NuxtPage />
<NuxtPage /> </NuxtLayout>
</NuxtLayout>
</div>
</template> </template>

292
components/Conversation.vue Normal file
View File

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

View File

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

View File

@@ -1,11 +1,15 @@
<script setup> <script setup>
const dialog = ref(false) const dialog = ref(false)
const currentModel = useCurrentModel() const currentModel = useCurrentModel()
const availableModels = [ const availableModels = [
DEFAULT_MODEL.name 'gpt-3.5-turbo',
'gpt-4'
] ]
const currentModelDefault = ref(MODELS[currentModel.value.name])
watch(currentModel, (newVal, oldVal) => { watch(currentModel, (newVal, oldVal) => {
currentModelDefault.value = MODELS[newVal.name]
saveCurrentModel(newVal) saveCurrentModel(newVal)
}, { deep: true }) }, { deep: true })
@@ -83,7 +87,7 @@ watch(currentModel, (newVal, oldVal) => {
single-line single-line
density="compact" density="compact"
type="number" type="number"
max="2048" :max="currentModelDefault.total_tokens"
step="1" step="1"
style="width: 100px" style="width: 100px"
class="flex-grow-0" class="flex-grow-0"
@@ -93,7 +97,7 @@ watch(currentModel, (newVal, oldVal) => {
<v-col cols="12"> <v-col cols="12">
<v-slider <v-slider
v-model="currentModel.max_tokens" v-model="currentModel.max_tokens"
:max="2048" :max="currentModelDefault.total_tokens"
:step="1" :step="1"
hide-details hide-details
> >

View File

@@ -12,14 +12,21 @@ const md = new MarkdownIt({
}, },
}) })
const props = defineProps(['content']) const props = defineProps({
message: {
type: Object,
required: true
}
})
const contentHtml = ref('') const contentHtml = ref('')
const contentElm = ref(null) const contentElm = ref(null)
watchEffect(() => { watchEffect(async () => {
contentHtml.value = props.content ? md.render(props.content) : '' contentHtml.value = props.message.message ? md.render(props.message.message) : ''
await nextTick()
bindCopyCodeToButtons()
}) })
const bindCopyCodeToButtons = () => { const bindCopyCodeToButtons = () => {
@@ -44,25 +51,28 @@ const bindCopyCodeToButtons = () => {
} }
onMounted(() => { onMounted(() => {
bindCopyCodeToButtons() console.log('mounted')
})
onUpdated(() => {
bindCopyCodeToButtons() bindCopyCodeToButtons()
}) })
</script> </script>
<template> <template>
<div <v-card
ref="contentElm" :color="message.is_bot ? '' : 'primary'"
v-html="contentHtml" rounded="lg"
class="chat-msg-content" elevation="2"
></div> >
<div
ref="contentElm"
v-html="contentHtml"
class="chat-msg-content pa-3"
></div>
</v-card>
</template> </template>
<style> <style>
.chat-msg-content ol { .chat-msg-content ol, .chat-msg-content ul {
padding-left: 2em; padding-left: 2em;
} }
.hljs-code-container { .hljs-code-container {

View File

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

View File

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

View File

@@ -2,6 +2,7 @@
const menu = ref(false) const menu = ref(false)
const prompts = ref([]) const prompts = ref([])
const editingPrompt = ref(null) const editingPrompt = ref(null)
const newTitlePrompt = ref(null)
const newPrompt = ref('') const newPrompt = ref('')
const submittingNewPrompt = ref(false) const submittingNewPrompt = ref(false)
const promptInputErrorMessage = ref('') const promptInputErrorMessage = ref('')
@@ -24,11 +25,13 @@ const addPrompt = async () => {
const { data, error } = await useAuthFetch('/api/chat/prompts/', { const { data, error } = await useAuthFetch('/api/chat/prompts/', {
method: 'POST', method: 'POST',
body: JSON.stringify({ body: JSON.stringify({
title: newTitlePrompt.value,
prompt: newPrompt.value prompt: newPrompt.value
}) })
}) })
if (!error.value) { if (!error.value) {
prompts.value.push(data.value) prompts.value.push(data.value)
newTitlePrompt.value = null
newPrompt.value = '' newPrompt.value = ''
} }
submittingNewPrompt.value = false submittingNewPrompt.value = false
@@ -43,6 +46,7 @@ const updatePrompt = async (index) => {
const { data, error } = await useAuthFetch(`/api/chat/prompts/${editingPrompt.value.id}/`, { const { data, error } = await useAuthFetch(`/api/chat/prompts/${editingPrompt.value.id}/`, {
method: 'PUT', method: 'PUT',
body: JSON.stringify({ body: JSON.stringify({
title: editingPrompt.value.title,
prompt: editingPrompt.value.prompt prompt: editingPrompt.value.prompt
}) })
}) })
@@ -82,7 +86,7 @@ const selectPrompt = (prompt) => {
menu.value = false menu.value = false
} }
onMounted( () => { onNuxtReady( () => {
loadPrompts() loadPrompts()
}) })
</script> </script>
@@ -96,10 +100,12 @@ onMounted( () => {
<template v-slot:activator="{ props }"> <template v-slot:activator="{ props }">
<v-btn <v-btn
v-bind="props" v-bind="props"
icon="speaker_notes" icon
title="Common prompts" >
class="mr-3" <v-icon
></v-btn> icon="speaker_notes"
></v-icon>
</v-btn>
</template> </template>
<v-container> <v-container>
@@ -108,7 +114,7 @@ onMounted( () => {
max-width="500" max-width="500"
> >
<v-card-title> <v-card-title>
<span class="headline">Frequently prompts</span> <span class="headline">{{ $t('frequentlyPrompts') }}</span>
</v-card-title> </v-card-title>
<v-divider></v-divider> <v-divider></v-divider>
@@ -125,35 +131,47 @@ onMounted( () => {
> >
<v-list-item <v-list-item
active-color="primary" active-color="primary"
rounded="xl"
v-if="editingPrompt && editingPrompt.id === prompt.id" v-if="editingPrompt && editingPrompt.id === prompt.id"
> >
<v-textarea <div class="d-flex flex-row" :style="{ marginTop: '5px' }">
rows="2" <div class="flex-grow-1">
v-model="editingPrompt.prompt" <v-text-field
:loading="editingPrompt.updating" v-model="editingPrompt.title"
variant="underlined" :loading="editingPrompt.updating"
hide-details :label="$t('titlePrompt')"
density="compact" variant="underlined"
> density="compact"
<template v-slot:append> hide-details
<div class="d-flex flex-column"> >
<v-btn </v-text-field>
icon="done" <v-textarea
variant="text" rows="2"
:loading="editingPrompt.updating" v-model="editingPrompt.prompt"
@click="updatePrompt(idx)" :loading="editingPrompt.updating"
> variant="underlined"
</v-btn> density="compact"
<v-btn hide-details
icon="close" >
variant="text" </v-textarea>
@click="cancelEditPrompt()" </div>
> <div>
</v-btn> <div class="d-flex flex-column">
</div> <v-btn
</template> icon="done"
</v-textarea> variant="text"
:loading="editingPrompt.updating"
@click="updatePrompt(idx)"
>
</v-btn>
<v-btn
icon="close"
variant="text"
@click="cancelEditPrompt()"
>
</v-btn>
</div>
</div>
</div>
</v-list-item> </v-list-item>
<v-list-item <v-list-item
v-if="!editingPrompt || editingPrompt.id !== prompt.id" v-if="!editingPrompt || editingPrompt.id !== prompt.id"
@@ -161,7 +179,7 @@ onMounted( () => {
active-color="primary" active-color="primary"
@click="selectPrompt(prompt)" @click="selectPrompt(prompt)"
> >
<v-list-item-title>{{ prompt.prompt }}</v-list-item-title> <v-list-item-title>{{ prompt.title ? prompt.title : prompt.prompt }}</v-list-item-title>
<template v-slot:append> <template v-slot:append>
<v-btn <v-btn
icon="edit" icon="edit"
@@ -182,6 +200,25 @@ onMounted( () => {
</v-list-item> </v-list-item>
</template> </template>
<v-list-item
active-color="primary"
>
<div
class="pt-3"
>
<v-text-field
rows="1"
v-model="newTitlePrompt"
:label="$t('titlePrompt')"
variant="outlined"
density="compact"
hide-details
clearable
>
</v-text-field>
</div>
</v-list-item>
<v-list-item <v-list-item
active-color="primary" active-color="primary"
> >
@@ -191,7 +228,7 @@ onMounted( () => {
<v-textarea <v-textarea
rows="2" rows="2"
v-model="newPrompt" v-model="newPrompt"
label="Add a new prompt" :label="$t('addNewPrompt')"
variant="outlined" variant="outlined"
density="compact" density="compact"
:error-messages="promptInputErrorMessage" :error-messages="promptInputErrorMessage"
@@ -209,7 +246,7 @@ onMounted( () => {
@click="addPrompt()" @click="addPrompt()"
> >
<v-icon icon="add"></v-icon> <v-icon icon="add"></v-icon>
Add prompt {{ $t('addPrompt') }}
</v-btn> </v-btn>
</v-list-item> </v-list-item>
</v-list> </v-list>

View File

@@ -4,10 +4,9 @@
<v-col cols="12"> <v-col cols="12">
<div class="text-center"> <div class="text-center">
<h2 class="text-h2">{{ $t('welcomeTo') }} <span class="text-primary">{{ runtimeConfig.public.appName }}</span></h2> <h2 class="text-h2">{{ $t('welcomeTo') }} <span class="text-primary">{{ runtimeConfig.public.appName }}</span></h2>
<p class="text-caption mt-5"> <p class="text-caption my-5">
{{ runtimeConfig.public.appName }} {{ $t('welcomeScreen.introduction1') }} {{ runtimeConfig.public.appName }} {{ $t('welcomeScreen.introduction1') }}
<br> <br>
{{ $t('welcomeScreen.introduction2') }}
</p> </p>
</div> </div>
</v-col> </v-col>

View File

@@ -15,26 +15,23 @@
</template> </template>
<v-card> <v-card>
<v-toolbar <v-toolbar
dark
color="primary"
> >
<v-btn <v-btn
icon icon
dark
@click="dialog = false" @click="dialog = false"
> >
<v-icon>close</v-icon> <v-icon icon="close"></v-icon>
</v-btn> </v-btn>
<v-toolbar-title>{{ $t('language') }}</v-toolbar-title> <v-toolbar-title>{{ $t('language') }}</v-toolbar-title>
<v-spacer></v-spacer> <v-spacer></v-spacer>
<!-- <v-toolbar-items>--> <v-toolbar-items>
<!-- <v-btn--> <v-btn
<!-- variant="text"--> variant="text"
<!-- @click="dialog = false"--> @click="dialog = false"
<!-- >--> >
<!-- Save--> Save
<!-- </v-btn>--> </v-btn>
<!-- </v-toolbar-items>--> </v-toolbar-items>
</v-toolbar> </v-toolbar>
<v-list <v-list
> >

19
composables/fetch.js Normal file
View File

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

View File

@@ -1,10 +1,14 @@
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 useConversations = () => useState('conversations', () => [])
export const useConversions = () => useState('conversions', () => []) export const useSettings = () => useState('settings', () => getSystemSettings())
export const useUser = () => useState('user', () => null)
export const useDrawer = () => useState('drawer', () => false)

View File

@@ -1,9 +0,0 @@
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/bmc_qr.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

BIN
demos/demo.mp4 Normal file

Binary file not shown.

View File

@@ -65,6 +65,6 @@ sudo curl -L "https://raw.githubusercontent.com/WongSaang/chatgpt-ui/main/docker
echo "Starting services..." 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 sudo APP_DOMAIN="${APP_DOMAIN}:${SERVER_PORT}" CLIENT_PORT=${CLIENT_PORT} SERVER_PORT=${SERVER_PORT} WSGI_PORT=${WSGI_PORT} docker-compose up --pull always -d
echo "Done" echo "Done"

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

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

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

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

View File

@@ -1,12 +1,10 @@
version: '3' version: '3'
services: services:
client: client:
platform: linux/x86_64
image: wongsaang/chatgpt-ui-client:latest image: wongsaang/chatgpt-ui-client:latest
environment: environment:
- SERVER_DOMAIN=http://backend-web-server - SERVER_DOMAIN=http://backend-web-server
- NUXT_PUBLIC_APP_NAME='ChatGPT UI'
- NUXT_PUBLIC_TYPEWRITER=true
- NUXT_PUBLIC_TYPEWRITER_DELAY=100
depends_on: depends_on:
- backend-web-server - backend-web-server
ports: ports:
@@ -15,25 +13,30 @@ services:
- chatgpt_ui_network - chatgpt_ui_network
restart: always restart: always
backend-wsgi-server: backend-wsgi-server:
platform: linux/x86_64
image: wongsaang/chatgpt-ui-wsgi-server:latest image: wongsaang/chatgpt-ui-wsgi-server:latest
environment: environment:
- APP_DOMAIN=${APP_DOMAIN:-localhost:9000} - APP_DOMAIN=${APP_DOMAIN:-localhost:9000}
- SERVER_WORKERS=3 # The number of worker processes for handling requests.
# - DB_URL=postgres://postgres:postgrespw@localhost:49153/chatgpt # If this parameter is not set, the built-in Sqlite will be used by default. It should be noted that if you do not connect to an external database, the data will be lost after the container is destroyed. # - 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_USERNAME=admin # default superuser name
- DJANGO_SUPERUSER_PASSWORD=password # default superuser password - DJANGO_SUPERUSER_PASSWORD=password # default superuser password
- DJANGO_SUPERUSER_EMAIL=admin@example.com # default superuser email - DJANGO_SUPERUSER_EMAIL=admin@example.com # default superuser email
- ACCOUNT_EMAIL_VERIFICATION=${ACCOUNT_EMAIL_VERIFICATION:-none} # Determines the e-mail verification method during signup choose one of "none", "optional", or "mandatory". Default is "optional". If you don't need to verify the email, you can set it to "none".
# If you want to use the email verification function, you need to configure the following parameters # If you want to use the email verification function, you need to configure the following parameters
# - EMAIL_HOST=SMTP server address # - EMAIL_HOST=SMTP server address
# - EMAIL_PORT=SMTP server port # - EMAIL_PORT=SMTP server port
# - EMAIL_HOST_USER= # - EMAIL_HOST_USER=
# - EMAIL_HOST_PASSWORD= # - EMAIL_HOST_PASSWORD=
# - EMAIL_USE_TLS=True # - EMAIL_USE_TLS=True
# - EMAIL_FROM=no-reply@example.com #Default sender email address
ports: ports:
- '${WSGI_PORT:-8000}:8000' - '${WSGI_PORT:-8000}:8000'
networks: networks:
- chatgpt_ui_network - chatgpt_ui_network
restart: always restart: always
backend-web-server: backend-web-server:
platform: linux/x86_64
image: wongsaang/chatgpt-ui-web-server:latest image: wongsaang/chatgpt-ui-web-server:latest
environment: environment:
- BACKEND_URL=http://backend-wsgi-server:8000 - BACKEND_URL=http://backend-wsgi-server:8000
@@ -47,4 +50,4 @@ services:
networks: networks:
chatgpt_ui_network: chatgpt_ui_network:
driver: bridge driver: bridge

View File

@@ -1,14 +1,27 @@
<p align="center"> <div align="center">
<img alt="demo" src="../../demos/demo.gif?v=1"> <h1>ChatGPT UI</h1>
</p> </div>
[English](../../README.md) | [中文](./docs/zh/README.md) [English](../../README.md) | [中文](./docs/zh/README.md)
# ChatGPT UI
ChatGPT Web 客户端,支持多用户,支持 Mysql、PostgreSQL 等多种数据库连接进行数据持久化存储,支持多语言。提供 Docker 镜像和快速部署脚本。 ChatGPT Web 客户端,支持多用户,支持 Mysql、PostgreSQL 等多种数据库连接进行数据持久化存储,支持多语言。提供 Docker 镜像和快速部署脚本。
https://user-images.githubusercontent.com/46235412/227156264-ca17ab17-999b-414f-ab06-3f75b5235bfe.mp4
## 📢 更新 ## 📢 更新
<details open>
<summary><strong>2023-03-27</strong></summary>
🚀 支持 gpt-4 模型。你可以在前端的“模型参数”中选择模型gpt-4 模型需要通过 openai 的白名单才能使用。
</details>
<details open>
<summary><strong>2023-03-23</strong></summary>
增加网页搜索能力,使得 ChatGPT 生成的回答更与时俱进!
该功能默认处于关闭状态,你可以在管理后台的 `Chat->Settings` 中开启它,在 Settings 中有一个 `open_web_search` 的记录,把它的值设置为 True。
</details>
<details open> <details open>
<summary><strong>2023-03-15</strong></summary> <summary><strong>2023-03-15</strong></summary>
@@ -16,17 +29,7 @@ ChatGPT Web 客户端,支持多用户,支持 Mysql、PostgreSQL 等多种数
</details> </details>
<details open> <details>
<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> <summary><strong>2023-03-04</strong></summary>
**使用最新的官方聊天模型** `gpt-3.5-turbo` **使用最新的官方聊天模型** `gpt-3.5-turbo`
@@ -88,9 +91,6 @@ services:
image: wongsaang/chatgpt-ui-client:latest image: wongsaang/chatgpt-ui-client:latest
environment: environment:
- SERVER_DOMAIN=http://backend-web-server - 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: depends_on:
- backend-web-server - backend-web-server
ports: ports:
@@ -101,17 +101,20 @@ services:
image: wongsaang/chatgpt-ui-wsgi-server:latest image: wongsaang/chatgpt-ui-wsgi-server:latest
environment: environment:
- APP_DOMAIN=${APP_DOMAIN:-localhost:9000} # CSRF 白名单,在这里设置为 chatgpt-ui-web-server 的地址+端口, 默认: localhost:9000 - APP_DOMAIN=${APP_DOMAIN:-localhost:9000} # CSRF 白名单,在这里设置为 chatgpt-ui-web-server 的地址+端口, 默认: localhost:9000
- SERVER_WORKERS=3 # gunicorn 的工作进程数,默认为 3
#- DB_URL=postgres://postgres:postgrespw@localhost:49153/chatgpt # 连接外部数据库,如果不设置这个参数,则默认使用内置的 Sqlite。需要注意的是如果不连接外部数据库数据将在容器销毁后丢失。链接格式请看下面的 DB_URL 格式对照表 #- DB_URL=postgres://postgres:postgrespw@localhost:49153/chatgpt # 连接外部数据库,如果不设置这个参数,则默认使用内置的 Sqlite。需要注意的是如果不连接外部数据库数据将在容器销毁后丢失。链接格式请看下面的 DB_URL 格式对照表
#- OPENAI_API_PROXY=https://openai.proxy.com/v1 # https://api.openai.com/v1 的代理地址 #- OPENAI_API_PROXY=https://openai.proxy.com/v1 # https://api.openai.com/v1 的代理地址
- DJANGO_SUPERUSER_USERNAME=admin # 默认超级用户 - DJANGO_SUPERUSER_USERNAME=admin # 默认超级用户
- DJANGO_SUPERUSER_PASSWORD=password # 默认超级用户的密码 - DJANGO_SUPERUSER_PASSWORD=password # 默认超级用户的密码
- DJANGO_SUPERUSER_EMAIL=admin@example.com # 默认超级用户邮箱 - DJANGO_SUPERUSER_EMAIL=admin@example.com # 默认超级用户邮箱
- ACCOUNT_EMAIL_VERIFICATION=none # 邮箱验证方式,可选值: none, optional, mandatory. 默认为 optional。如果你不需要验证用户的邮箱可以设置为 none。
# 如果您想使用电子邮件验证功能,需要配置以下参数: # 如果您想使用电子邮件验证功能,需要配置以下参数:
# - EMAIL_HOST=SMTP server address # - EMAIL_HOST=SMTP server address
# - EMAIL_PORT=SMTP server port # - EMAIL_PORT=SMTP server port
# - EMAIL_HOST_USER= # - EMAIL_HOST_USER=
# - EMAIL_HOST_PASSWORD= # - EMAIL_HOST_PASSWORD=
# - EMAIL_USE_TLS=True # - EMAIL_USE_TLS=True
# - EMAIL_FROM=no-reply@example.com #默认发件邮箱地址
ports: ports:
- '8000:8000' - '8000:8000'
networks: networks:
@@ -153,6 +156,16 @@ networks:
现在可以访问客户端地址 `http(s)://your.domain` / `http://123.123.123.123` 开始聊天。 现在可以访问客户端地址 `http(s)://your.domain` / `http://123.123.123.123` 开始聊天。
## 续杯咖啡
> 如果对您有帮助,也是在帮助我自己.
如果你想支持我,给我续杯咖啡吧 ❤️ [https://www.buymeacoffee.com/WongSaang](https://www.buymeacoffee.com/WongSaang)
<p align="center">
<img height="150" src="https://github.com/WongSaang/chatgpt-ui/blob/main/demos/bmc_qr.png?raw=true"/>
</p>
## Development ## Development
### Setup ### Setup
@@ -178,4 +191,4 @@ Build the application for production:
```bash ```bash
yarn build yarn build
``` ```

View File

@@ -10,6 +10,10 @@
"saveAndClose": "Save & Close", "saveAndClose": "Save & Close",
"pleaseSelectAtLeastOneModelDot": "Please select at least one model.", "pleaseSelectAtLeastOneModelDot": "Please select at least one model.",
"writeAMessage": "Write a message", "writeAMessage": "Write a message",
"frequentlyPrompts": "Frequently prompts",
"addPrompt": "Add prompt",
"titlePrompt": "Title",
"addNewPrompt": "Add a new prompt",
"pressEnterToSendYourMessageOrShiftEnterToAddANewLine": "Press Enter to send your message or Shift+Enter to add a new line", "pressEnterToSendYourMessageOrShiftEnterToAddANewLine": "Press Enter to send your message or Shift+Enter to add a new line",
"lightMode": "Light Mode", "lightMode": "Light Mode",
"darkMode": "Dark Mode", "darkMode": "Dark Mode",
@@ -17,6 +21,7 @@
"themeMode": "Theme Mode", "themeMode": "Theme Mode",
"feedback": "Feedback", "feedback": "Feedback",
"newConversation": "New conversation", "newConversation": "New conversation",
"defaultConversationTitle": "Unnamed",
"clearConversations": "Clear conversations", "clearConversations": "Clear conversations",
"modelParameters": "Model Parameters", "modelParameters": "Model Parameters",
"model": "Model", "model": "Model",
@@ -29,6 +34,22 @@
"me": "Me", "me": "Me",
"ai": "AI" "ai": "AI"
}, },
"edit": "Edit",
"copy": "Copy",
"copied": "Copied",
"delete": "Delete",
"signOut": "Sign out",
"resetPassword": "Reset password",
"submit": "Submit",
"agree": "Agree",
"newPassword": "New password",
"currentPassword": "Current password",
"confirmPassword": "Confirm password",
"yourPasswordHasBeenReset": "Your password has been reset",
"nowYouNeedToSignInAgain": "Now you need to sign in again",
"webSearch": "Web Search",
"webSearchDefaultPrompt": "Web search results:\n\n[web_results]\nCurrent date: [current_date]\n\nInstructions: Using the provided web search results, write a comprehensive reply to the given query. Make sure to cite results using [[number](URL)] notation after the reference. If the provided search results refer to multiple subjects with the same name, write separate answers for each subject.\nQuery: [query]",
"genTitlePrompt": "Generate a short title for the following content, no more than 10 words. \n\nContent: ",
"welcomeScreen": { "welcomeScreen": {
"introduction1": "is an unofficial client for ChatGPT, but uses the official OpenAI API.", "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.", "introduction2": "You will need an OpenAI API Key before you can use this client.",

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

@@ -0,0 +1,75 @@
{
"welcomeTo": "Добро пожаловать в",
"language": "Язык",
"setApiKey": "Установить ключ API",
"setOpenAIApiKey": "Установить ключ API OpenAI",
"openAIApiKey": "Ключ API OpenAI",
"getAKey": "Получить ключ",
"openAIModels": "Модели OpenAI",
"aboutTheModels": "О моделях",
"saveAndClose": "Сохранить & Закрыть",
"pleaseSelectAtLeastOneModelDot": "Выберите хотя бы одну модель.",
"writeAMessage": "Напишите сообщение",
"frequentlyPrompts": "Список подсказок",
"addPrompt": "Добавить подсказку",
"titlePrompt": "Заголовок",
"addNewPrompt": "Добавитьте новую подсказку",
"pressEnterToSendYourMessageOrShiftEnterToAddANewLine": "Нажмите Enter, чтобы отправить сообщение, или Shift+Enter, чтобы добавить новую строку.",
"lightMode": "Светлая",
"darkMode": "Темная",
"followSystem": "Системная",
"themeMode": "Тема",
"feedback": "Обратная связь",
"newConversation": "Новый чат",
"defaultConversationTitle": "Безымянный",
"clearConversations": "Очистить чаты",
"modelParameters": "Параметры модели",
"model": "Модель",
"temperature": "Temperature",
"topP": "Top P",
"frequencyPenalty": "Frequency Penalty",
"presencePenalty": "Presence Penalty",
"maxTokens": "Max Tokens",
"roles": {
"me": "Я",
"ai": "AI"
},
"edit": "Редактировать",
"copy": "Копировать",
"copied": "Скопировано",
"delete": "Удалить",
"signOut": "Выход",
"resetPassword": "Сбросить пароль",
"submit": "Отправить",
"agree": "Согласен",
"newPassword": "Новый пароль",
"currentPassword": "Текущий пароль",
"confirmPassword": "Подтвердите пароль",
"yourPasswordHasBeenReset": "Ваш пароль был сброшен",
"nowYouNeedToSignInAgain": "Теперь вам нужно снова войти в систему",
"webSearch": "Поиск в интернете",
"webSearchDefaultPrompt": "Результаты веб-поиска:\n\n[web_results]\nТекущая дата: [current_date]\n\nИнструкции: Используя предоставленные результаты веб-поиска, напишите развернутый ответ на заданный запрос. Обязательно цитируйте результаты, используя обозначение [[number](URL)] после ссылки. Если предоставленные результаты поиска относятся к нескольким темам с одинаковым названием, напишите отдельные ответы для каждой темы.\nЗапрос: [query]",
"genTitlePrompt": "Придумайте короткий заголовок для следующего содержания, не более 10 слов. \n\nСодержание: ",
"welcomeScreen": {
"introduction1": "является неофициальным клиентом для ChatGPT, но использует официальный API OpenAI.",
"introduction2": "Вам понадобится ключ API OpenAI, прежде чем вы сможете использовать этот клиент.",
"examples": {
"title": "Примеры",
"item1": "\"Объясни, что такое квантовые вычисления простыми словами\"",
"item2": "\"Предложи несколько креативных идей для дня рождения 10-летнего ребенка?\"",
"item3": "\"Как сделать HTTP-запрос в Javascript?\""
},
"capabilities": {
"title": "Возможности",
"item1": "Помнит, что пользователь сказал ранее в разговоре",
"item2": "Позволяет пользователю вносить последующие исправления",
"item3": "Научен отклонять неуместные запросы"
},
"limitations": {
"title": "Ограничения",
"item1": "Иногда может генерировать неверную информацию",
"item2": "Иногда может создавать вредные инструкции или предвзятый контент",
"item3": "Ограниченное знание мира и событий после 2021 года"
}
}
}

View File

@@ -10,6 +10,10 @@
"saveAndClose": "保存并关闭", "saveAndClose": "保存并关闭",
"pleaseSelectAtLeastOneModelDot": "请至少选择一个模型", "pleaseSelectAtLeastOneModelDot": "请至少选择一个模型",
"writeAMessage": "输入信息", "writeAMessage": "输入信息",
"frequentlyPrompts": "Frequently prompts",
"addPrompt": "Add prompt",
"titlePrompt": "Title",
"addNewPrompt": "Add a new prompt",
"pressEnterToSendYourMessageOrShiftEnterToAddANewLine": "按回车键发送您的信息或按Shift+Enter键添加新行", "pressEnterToSendYourMessageOrShiftEnterToAddANewLine": "按回车键发送您的信息或按Shift+Enter键添加新行",
"lightMode": "明亮模式", "lightMode": "明亮模式",
"darkMode": "暗色模式", "darkMode": "暗色模式",
@@ -17,6 +21,7 @@
"themeMode": "主题模式", "themeMode": "主题模式",
"feedback": "反馈", "feedback": "反馈",
"newConversation": "新的对话", "newConversation": "新的对话",
"defaultConversationTitle": "未命名",
"clearConversations": "清除对话", "clearConversations": "清除对话",
"modelParameters": "模型参数", "modelParameters": "模型参数",
"model": "模型", "model": "模型",
@@ -29,6 +34,22 @@
"me": "我", "me": "我",
"ai": "AI" "ai": "AI"
}, },
"edit": "编辑",
"copy": "复制",
"copied": "已复制",
"delete": "删除",
"signOut": "退出登录",
"resetPassword": "重置密码",
"submit": "提交",
"agree": "同意",
"newPassword": "新密码",
"currentPassword": "当前密码",
"confirmPassword": "确认密码",
"yourPasswordHasBeenReset": "您的密码已重置",
"nowYouNeedToSignInAgain": "现在您需要再次登录",
"webSearch": "网页搜索",
"webSearchDefaultPrompt": "网络搜索结果:\n\n[web_results]\n当前日期[current_date]\n\n说明使用提供的网络搜索结果对给定的查询写出全面的回复。确保在引用参考文献后使用 [[number](URL)] 符号进行引用结果. 如果提供的搜索结果涉及到多个具有相同名称的主题,请针对每个主题编写单独的答案。\n查询[query]",
"genTitlePrompt": "为以下内容生成一个不超过10个字的简短标题。 \n\n内容: ",
"welcomeScreen": { "welcomeScreen": {
"introduction1": "是一个非官方的ChatGPT客户端但使用OpenAI的官方API", "introduction1": "是一个非官方的ChatGPT客户端但使用OpenAI的官方API",
"introduction2": "在使用本客户端之前您需要一个OpenAI API密钥。", "introduction2": "在使用本客户端之前您需要一个OpenAI API密钥。",

View File

@@ -1,387 +1,8 @@
<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> <template>
<v-app <v-app
:theme="$colorMode.value" :theme="$colorMode.value"
> >
<v-navigation-drawer <NavigationDrawer />
v-model="drawer" <slot />
: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> </v-app>
</template> </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>

18
middleware/auth.ts Normal file
View File

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

View File

@@ -1,9 +1,9 @@
// https://nuxt.com/docs/api/configuration/nuxt-config // https://nuxt.com/docs/api/configuration/nuxt-config
const appName = 'ChatGPT UI' const appName = process.env.NUXT_PUBLIC_APP_NAME ?? 'ChatGPT UI'
export default defineNuxtConfig({ export default defineNuxtConfig({
dev: false, debug: process.env.NODE_ENV !== 'production',
ssr: false, ssr: true,
app: { app: {
head: { head: {
title: appName, title: appName,
@@ -14,6 +14,7 @@ export default defineNuxtConfig({
appName: appName, appName: appName,
typewriter: false, typewriter: false,
typewriterDelay: 50, typewriterDelay: 50,
customApiKey: false
} }
}, },
build: { build: {
@@ -25,36 +26,18 @@ export default defineNuxtConfig({
'highlight.js/styles/panda-syntax-dark.css', 'highlight.js/styles/panda-syntax-dark.css',
], ],
modules: [ modules: [
'@vite-pwa/nuxt', '@kevinmarrec/nuxt-pwa',
'@nuxtjs/color-mode', '@nuxtjs/color-mode',
'@nuxtjs/i18n', '@nuxtjs/i18n'
], ],
pwa: { pwa: {
registerType: 'autoUpdate',
manifest: { manifest: {
name: appName, name: appName,
short_name: appName, short_name: appName,
icons: [ description: 'A ChatGPT web Client'
{
src: 'icon-black.svg',
sizes: '900x900',
purpose: 'any maskable',
}
],
}, },
workbox: { workbox: {
navigateFallback: '/', enabled: process.env.DEBUT_PWA === 'true',
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: { i18n: {
@@ -71,6 +54,12 @@ export default defineNuxtConfig({
iso: 'zh-CN', iso: 'zh-CN',
name: '简体中文', name: '简体中文',
file: 'zh-CN.json', file: 'zh-CN.json',
},
{
code: 'ru',
iso: 'ru-RU',
name: 'Русский',
file: 'ru-RU.json',
} }
], ],
lazy: true, lazy: true,
@@ -79,15 +68,5 @@ export default defineNuxtConfig({
vueI18n: { vueI18n: {
fallbackLocale: 'en', fallbackLocale: 'en',
}, },
}, }
nitro: {
devProxy: {
"/api": {
target: "http://localhost:8000/api",
prependPath: true,
changeOrigin: true,
}
}
},
}) })

View File

@@ -8,14 +8,14 @@
"postinstall": "nuxt prepare" "postinstall": "nuxt prepare"
}, },
"devDependencies": { "devDependencies": {
"@kevinmarrec/nuxt-pwa": "^0.17.0",
"@nuxtjs/color-mode": "^3.2.0", "@nuxtjs/color-mode": "^3.2.0",
"@nuxtjs/i18n": "^8.0.0-beta.9", "@nuxtjs/i18n": "^8.0.0-beta.9",
"material-design-icons-iconfont": "^6.7.0", "material-design-icons-iconfont": "^6.7.0",
"nuxt": "^3.2.0" "nuxt": "^3.3.3"
}, },
"dependencies": { "dependencies": {
"@microsoft/fetch-event-source": "^2.0.1", "@microsoft/fetch-event-source": "^2.0.1",
"@vite-pwa/nuxt": "^0.0.7",
"copy-to-clipboard": "^3.3.3", "copy-to-clipboard": "^3.3.3",
"highlight.js": "^11.7.0", "highlight.js": "^11.7.0",
"is-mobile": "^3.1.1", "is-mobile": "^3.1.1",

View File

@@ -7,6 +7,7 @@ const route = useRoute()
const sending = ref(false) const sending = ref(false)
const resent = ref(false) const resent = ref(false)
const errorMsg = ref(null) const errorMsg = ref(null)
const user = useUser()
const resendEmail = async () => { const resendEmail = async () => {
errorMsg.value = null errorMsg.value = null
sending.value = true sending.value = true
@@ -45,24 +46,32 @@ onNuxtReady(() => {
elevation="0" elevation="0"
> >
<div class="text-center"> <div class="text-center">
<h2 class="text-h4">Verify your email</h2> <div v-if="route.query.email_verification_required && route.query.email_verification_required === 'none'">
<p class="text-body-2 mt-5"> <h2 class="text-h4">Your registration is successful</h2>
We've sent a verification email to <strong>{{ $auth.user.email }}</strong>. <br> <p class="mt-5">
Please check your inbox and click the link to verify your email address. You can now <NuxtLink to="/account/signin">login</NuxtLink> to your account.
</p> </p>
<p v-if="errorMsg" </div>
class="text-red" <div v-else>
>{{ errorMsg }}</p> <h2 class="text-h4">Verify your email</h2>
<v-btn <p class="mt-5">
variant="text" We've sent a verification email to <strong>{{ user.email }}</strong>. <br>
class="mt-5" Please check your inbox and click the link to verify your email address.
color="primary" </p>
:loading="sending" <p v-if="errorMsg"
@click="resendEmail" class="text-red"
:disabled="resent" >{{ errorMsg }}</p>
> <v-btn
{{ resent ? 'Resent' : 'Resend email'}} variant="text"
</v-btn> class="mt-5"
color="primary"
:loading="sending"
@click="resendEmail"
:disabled="resent"
>
{{ resent ? 'Resent' : 'Resend email'}}
</v-btn>
</div>
</div> </div>
</v-card> </v-card>
</v-col> </v-col>

View File

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

View File

@@ -67,6 +67,8 @@
</template> </template>
<script setup> <script setup>
import {useUser} from "~/composables/states";
definePageMeta({ definePageMeta({
layout: 'vuetify-app' layout: 'vuetify-app'
}) })
@@ -82,10 +84,8 @@ const formRules = ref({
v => !!v || 'Password is required' v => !!v || 'Password is required'
] ]
}) })
const { $auth } = useNuxtApp()
const errorMsg = ref(null) const errorMsg = ref(null)
const signInForm = ref(null) const signInForm = ref(null)
const valid = ref(true)
const submitting = ref(false) const submitting = ref(false)
const route = useRoute() const route = useRoute()
const passwordInputType = ref('password') const passwordInputType = ref('password')
@@ -99,6 +99,7 @@ const submit = async () => {
method: 'POST', method: 'POST',
body: JSON.stringify(formData.value) body: JSON.stringify(formData.value)
}) })
submitting.value = false
if (error.value) { if (error.value) {
if (error.value.status === 400) { if (error.value.status === 400) {
if (error.value.data.non_field_errors) { if (error.value.data.non_field_errors) {
@@ -108,10 +109,10 @@ const submit = async () => {
errorMsg.value = 'Something went wrong. Please try again.' errorMsg.value = 'Something went wrong. Please try again.'
} }
} else { } else {
$auth.setUser(data.value.user) setUser(data.value.user)
navigateTo(route.query.callback || '/') const callback = route.query.callback ? decodeURIComponent(route.query.callback) : '/'
await navigateTo(callback)
} }
submitting.value = false
} }
} }

View File

@@ -3,8 +3,6 @@ definePageMeta({
layout: 'vuetify-app' layout: 'vuetify-app'
}) })
const { $auth } = useNuxtApp()
const formData = ref({ const formData = ref({
username: '', username: '',
email: '', email: '',
@@ -74,8 +72,8 @@ const submit = async () => {
} }
} }
} else { } else {
$auth.setUser(data.value.user) setUser(data.value.user)
navigateTo('/account/onboarding') navigateTo('/account/onboarding?email_verification_required='+data.value.email_verification_required)
} }
submitting.value = false submitting.value = false
@@ -83,7 +81,7 @@ const submit = async () => {
} }
const handleFieldUpdate = (field) => { const handleFieldUpdate = (field) => {
// fieldErrors.value[field] = '' fieldErrors.value[field] = ''
} }
</script> </script>
@@ -122,7 +120,7 @@ const handleFieldUpdate = (field) => {
:error-messages="fieldErrors.email" :error-messages="fieldErrors.email"
label="Email" label="Email"
variant="underlined" variant="underlined"
@@update:modelValue="handleFieldUpdate('email')" @update:modelValue="handleFieldUpdate('email')"
clearable clearable
></v-text-field> ></v-text-field>

View File

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

View File

@@ -1,71 +0,0 @@
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
}
}
})

BIN
public/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

2
public/robots.txt Normal file
View File

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

View File

@@ -0,0 +1,33 @@
const PayloadMethods = new Set(["PATCH", "POST", "PUT", "DELETE"]);
export default defineEventHandler(async (event) => {
// @ts-ignore
if (event.node.req.url.startsWith('/api/')) {
// TODO: fix fetch failed
const target = (process.env.SERVER_DOMAIN || 'http://localhost:8000') + event.node.req.url
// Method
const method = getMethod(event)
// Body
let body;
if (PayloadMethods.has(method)) {
body = await readRawBody(event).catch(() => undefined);
}
// Headers
const headers = getProxyRequestHeaders(event);
if (method === 'DELETE') {
delete headers['content-length']
}
return sendProxy(event, target, {
sendStream: event.node.req.url === '/api/conversation/',
fetchOptions: {
headers,
method,
body,
},
});
}
})

View File

@@ -5,11 +5,25 @@ export const STORAGE_KEY = {
OPENAI_API_KEY: 'openai_api_key', OPENAI_API_KEY: 'openai_api_key',
} }
export const DEFAULT_MODEL = { export const MODELS = {
name: 'gpt-3.5-turbo', 'gpt-3.5-turbo': {
frequency_penalty: 0.0, name: 'gpt-3.5-turbo',
presence_penalty: 0.0, frequency_penalty: 0.0,
max_tokens: 1000, presence_penalty: 0.0,
temperature: 0.7, total_tokens: 4096,
top_p: 1.0 max_tokens: 1000,
} temperature: 0.7,
top_p: 1.0
},
'gpt-4': {
name: 'gpt-4',
frequency_penalty: 0.0,
presence_penalty: 0.0,
total_tokens: 8192,
max_tokens: 2000,
temperature: 0.7,
top_p: 1.0
}
}
export const DEFAULT_MODEL_NAME = 'gpt-3.5-turbo'

View File

@@ -1,5 +1,6 @@
export const getDefaultConversionData = () => { export const getDefaultConversationData = () => {
const { $i18n } = useNuxtApp()
return { return {
id: null, id: null,
topic: null, topic: null,
@@ -8,7 +9,7 @@ export const getDefaultConversionData = () => {
} }
} }
export const getConversions = async () => { export const getConversations = async () => {
const { data, error } = await useAuthFetch('/api/chat/conversations/') const { data, error } = await useAuthFetch('/api/chat/conversations/')
if (!error.value) { if (!error.value) {
return data.value return data.value
@@ -16,38 +17,63 @@ export const getConversions = async () => {
return [] return []
} }
export const createNewConversion = () => { export const addConversation = (conversation) => {
const conversation = useConversion() const conversations = useConversations()
conversation.value = getDefaultConversionData() conversations.value = [conversation, ...conversations.value]
} }
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) => { export const genTitle = async (conversationId) => {
const { $i18n } = useNuxtApp()
const { data, error } = await useAuthFetch('/api/gen_title/', { const { data, error } = await useAuthFetch('/api/gen_title/', {
method: 'POST', method: 'POST',
body: { body: {
conversationId: conversationId conversationId: conversationId,
prompt: $i18n.t('genTitlePrompt')
} }
}) })
if (!error.value) { if (!error.value) {
const conversation = { const conversations = useConversations()
id: conversationId, let index = conversations.value.findIndex(item => item.id === conversationId)
topic: data.value.title, if (index === -1) {
index = 0
} }
const conversations = useConversions() conversations.value[index].topic = data.value.title
// prepend to conversations
conversations.value = [conversation, ...conversations.value]
return data.value.title return data.value.title
} }
return null return null
}
const transformData = (list) => {
const result = {};
for (let i = 0; i < list.length; i++) {
const item = list[i];
result[item.name] = item.value;
}
return result;
}
export const getSystemSettings = async () => {
const { data, error } = await useAuthFetch('/api/chat/settings/', {
method: 'GET',
})
if (!error.value) {
return transformData(data.value)
}
return {}
}
export const fetchUser = async () => {
return useMyFetch('/api/account/user/')
}
export const setUser = (userData) => {
const user = useUser()
user.value = userData
}
export const logout = () => {
const user = useUser()
user.value = null
return navigateTo('/account/signin');
} }

View File

@@ -1,5 +1,6 @@
const get = (key) => { const get = (key) => {
if (process.server) return
let val = localStorage.getItem(key) let val = localStorage.getItem(key)
if (val) { if (val) {
val = JSON.parse(val) val = JSON.parse(val)
@@ -8,6 +9,7 @@ const get = (key) => {
} }
const set = (key, val) => { const set = (key, val) => {
if (process.server) return
localStorage.setItem(key, JSON.stringify(val)) localStorage.setItem(key, JSON.stringify(val))
} }
@@ -17,13 +19,13 @@ export const setModels = (val) => {
models.value = val models.value = val
} }
export const getStoredModels = () => { // export const getStoredModels = () => {
let models = get(STORAGE_KEY.MODELS) // let models = get(STORAGE_KEY.MODELS)
if (!models) { // if (!models) {
models = [DEFAULT_MODEL] // models = [DEFAULT_MODEL]
} // }
return models // return models
} // }
export const saveCurrentModel = (val) => { export const saveCurrentModel = (val) => {
set(STORAGE_KEY.CURRENT_MODEL, val) set(STORAGE_KEY.CURRENT_MODEL, val)
@@ -32,7 +34,7 @@ export const saveCurrentModel = (val) => {
export const getCurrentModel = () => { export const getCurrentModel = () => {
let model = get(STORAGE_KEY.CURRENT_MODEL) let model = get(STORAGE_KEY.CURRENT_MODEL)
if (!model) { if (!model) {
model = DEFAULT_MODEL model = MODELS[DEFAULT_MODEL_NAME]
} }
return model return model
} }

4045
yarn.lock

File diff suppressed because it is too large Load Diff