Compare commits

...

41 Commits

Author SHA1 Message Date
Rafi
fa14276d0a Fix: Resending message when the visibility of the browser page changes , which causes slowdown or failure to receive messages 2023-04-11 10:30:12 +08:00
Rafi
8718dc4ed1 using SERVER_DOMAIN at proxy target 2023-04-10 18:15:18 +08:00
Rafi
fe814acfd9 using http-proxy-middleware 2023-04-10 18:05:07 +08:00
Rafi
1e4f14c9b7 add user guide to readme 2023-04-07 19:39:14 +08:00
Rafi
137ca5ae1a Support Frugal Mode 2023-04-06 18:00:24 +08:00
Rafi
8a9b705b99 Fix the issue where useSettings does not work in SSR mode. 2023-04-06 16:07:46 +08:00
Rafi
82c1811034 Fix the issue of updating the number to a string type when clicking the plus or minus button in the input box when setting the model parameters. 2023-04-06 15:08:35 +08:00
Rafi
0d6aef6872 update readme 2023-04-06 10:54:36 +08:00
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
35 changed files with 2304 additions and 3889 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

@@ -4,12 +4,28 @@
[English](./README.md) | [中文](./docs/zh/README.md) [English](./README.md) | [中文](./docs/zh/README.md)
User guide: [https://wongsaang.github.io/chatgpt-ui-docs/](https://wongsaang.github.io/chatgpt-ui-docs/)
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.
The server of this project[https://github.com/WongSaang/chatgpt-ui-server](https://github.com/WongSaang/chatgpt-ui-server)
https://user-images.githubusercontent.com/46235412/227156264-ca17ab17-999b-414f-ab06-3f75b5235bfe.mp4 https://user-images.githubusercontent.com/46235412/227156264-ca17ab17-999b-414f-ab06-3f75b5235bfe.mp4
## 📢Updates ## 📢Updates
<details open>
<summary><strong>2023-04-06</strong></summary>
The client is now deployed as server-side rendering (SSR), and the environment variables are now available, see docker-compose configuration below for available environment variables. Improved first screen loading speed and reduced white screen time.
</details>
<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> <details open>
<summary><strong>2023-03-23</strong></summary> <summary><strong>2023-03-23</strong></summary>
Added web search capability to generate more relevant and up-to-date answers from ChatGPT! Added web search capability to generate more relevant and up-to-date answers from ChatGPT!
@@ -25,17 +41,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`
@@ -98,9 +104,9 @@ 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_APP_NAME='ChatGPT UI' # The name of the application
- NUXT_PUBLIC_TYPEWRITER=true # Enable typewriter effect, default is false # - NUXT_PUBLIC_TYPEWRITER=true # Whether to enable the typewriter effect, default false
- NUXT_PUBLIC_TYPEWRITER_DELAY=100 # Typewriter effect delay time, default is 50ms # - NUXT_PUBLIC_TYPEWRITER_DELAY=50 # The delay time of the typewriter effect, default 50ms
depends_on: depends_on:
- backend-web-server - backend-web-server
ports: ports:
@@ -111,6 +117,7 @@ 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

13
app.vue
View File

@@ -1,8 +1,15 @@
<script setup>
onNuxtReady(() => {
fetchSystemSettings()
// api key
const apiKey = useApiKey()
apiKey.value = getStoredApiKey()
})
</script>
<template> <template>
<div>
<NuxtLoadingIndicator />
<NuxtLayout> <NuxtLayout>
<NuxtLoadingIndicator />
<NuxtPage /> <NuxtPage />
</NuxtLayout> </NuxtLayout>
</div>
</template> </template>

328
components/Conversation.vue Normal file
View File

@@ -0,0 +1,328 @@
<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 = []
const frugalMode = ref(true)
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,
frugalMode: frugalMode.value
}, webSearchParams)
try {
await fetchEventSource('/api/conversation/', {
signal: ctrl.signal,
method: 'POST',
headers: {
'accept': 'application/json',
'Content-Type': 'application/json',
},
body: JSON.stringify(data),
openWhenHidden: true,
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 settings = useSettings()
const enableWebSearch = ref(false)
const showWebSearchToggle = computed(() => {
return settings.value && settings.value.open_web_search && settings.value.open_web_search === 'True'
})
const enableCustomApiKey = computed(() => {
return settings.value && settings.value.open_api_key_setting && settings.value.open_api_key_setting === 'True'
})
onNuxtReady(() => {
currentModel.value = getCurrentModel()
})
</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"
inline
hide-details
color="primary"
:label="$t('webSearch')"
></v-switch>
<v-spacer></v-spacer>
<v-switch
v-model="frugalMode"
inline
hide-details
color="primary"
:label="$t('frugalMode')"
></v-switch>
<v-dialog
transition="dialog-bottom-transition"
width="auto"
>
<template v-slot:activator="{ props }">
<v-icon
color="grey"
v-bind="props"
icon="help_outline"
></v-icon>
</template>
<template v-slot:default="{ isActive }">
<v-card>
<v-toolbar
color="primary"
:title="$t('frugalMode')"
></v-toolbar>
<v-card-text>
{{ $t('frugalModeTip') }}
</v-card-text>
</v-card>
</template>
</v-dialog>
</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

@@ -1,13 +1,20 @@
<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) => { onNuxtReady(() => {
currentModel.value = getCurrentModel()
watch(currentModel, (newVal, oldVal) => {
currentModelDefault.value = MODELS[newVal.name]
saveCurrentModel(newVal) saveCurrentModel(newVal)
}, { deep: true }) }, { deep: true })
})
</script> </script>
@@ -49,7 +56,7 @@ watch(currentModel, (newVal, oldVal) => {
<div class="d-flex justify-space-between align-center"> <div class="d-flex justify-space-between align-center">
<v-list-subheader>{{ $t('temperature') }}</v-list-subheader> <v-list-subheader>{{ $t('temperature') }}</v-list-subheader>
<v-text-field <v-text-field
v-model="currentModel.temperature" v-model.number="currentModel.temperature"
hide-details hide-details
single-line single-line
density="compact" density="compact"
@@ -78,22 +85,25 @@ watch(currentModel, (newVal, oldVal) => {
<div class="d-flex justify-space-between align-center"> <div class="d-flex justify-space-between align-center">
<v-list-subheader>{{ $t('maxTokens') }}</v-list-subheader> <v-list-subheader>{{ $t('maxTokens') }}</v-list-subheader>
<v-text-field <v-text-field
v-model="currentModel.max_tokens" v-model.number="currentModel.max_tokens"
hide-details hide-details
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"
></v-text-field> ></v-text-field>
</div> </div>
<div class="text-caption">
{{ $t('maxTokenTips1') }} <b>{{ currentModelDefault.total_tokens }}</b> {{ $t('maxTokenTips2') }}
</div>
</v-col> </v-col>
<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
> >
@@ -107,7 +117,7 @@ watch(currentModel, (newVal, oldVal) => {
<div class="d-flex justify-space-between align-center"> <div class="d-flex justify-space-between align-center">
<v-list-subheader>{{ $t('topP') }}</v-list-subheader> <v-list-subheader>{{ $t('topP') }}</v-list-subheader>
<v-text-field <v-text-field
v-model="currentModel.top_p" v-model.number="currentModel.top_p"
hide-details hide-details
single-line single-line
density="compact" density="compact"
@@ -134,7 +144,7 @@ watch(currentModel, (newVal, oldVal) => {
<div class="d-flex justify-space-between align-center"> <div class="d-flex justify-space-between align-center">
<v-list-subheader>{{ $t('frequencyPenalty') }}</v-list-subheader> <v-list-subheader>{{ $t('frequencyPenalty') }}</v-list-subheader>
<v-text-field <v-text-field
v-model="currentModel.frequency_penalty" v-model.number="currentModel.frequency_penalty"
hide-details hide-details
single-line single-line
density="compact" density="compact"
@@ -160,7 +170,7 @@ watch(currentModel, (newVal, oldVal) => {
<div class="d-flex justify-space-between align-center"> <div class="d-flex justify-space-between align-center">
<v-list-subheader>{{ $t('presencePenalty') }}</v-list-subheader> <v-list-subheader>{{ $t('presencePenalty') }}</v-list-subheader>
<v-text-field <v-text-field
v-model="currentModel.presence_penalty" v-model.number="currentModel.presence_penalty"
hide-details hide-details
single-line single-line
density="compact" density="compact"

View File

@@ -23,8 +23,10 @@ const contentHtml = ref('')
const contentElm = ref(null) const contentElm = ref(null)
watchEffect(() => { watchEffect(async () => {
contentHtml.value = props.message.message ? md.render(props.message.message) : '' contentHtml.value = props.message.message ? md.render(props.message.message) : ''
await nextTick()
bindCopyCodeToButtons()
}) })
const bindCopyCodeToButtons = () => { const bindCopyCodeToButtons = () => {
@@ -52,10 +54,6 @@ onMounted(() => {
bindCopyCodeToButtons() bindCopyCodeToButtons()
}) })
onUpdated(() => {
bindCopyCodeToButtons()
})
</script> </script>
<template> <template>
@@ -64,18 +62,16 @@ onUpdated(() => {
rounded="lg" rounded="lg"
elevation="2" elevation="2"
> >
<v-card-text>
<div <div
ref="contentElm" ref="contentElm"
v-html="contentHtml" v-html="contentHtml"
class="chat-msg-content" class="chat-msg-content pa-3"
></div> ></div>
</v-card-text>
</v-card> </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,3 +1,73 @@
<script setup>
import { isMobile } from 'is-mobile'
const { $i18n } = useNuxtApp()
const props = defineProps({
sendMessage: {
type: Function,
required: true
},
disabled: {
type: Boolean,
default: false
},
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>
<template> <template>
<div <div
class="flex-grow-1 d-flex align-center justify-space-between" class="flex-grow-1 d-flex align-center justify-space-between"
@@ -25,67 +95,3 @@
></v-btn> ></v-btn>
</div> </div>
</template> </template>
<script>
import { isMobile } from 'is-mobile'
export default {
name: "MsgEditor",
props: {
sendMessage: Function,
disabled: Boolean,
loading: Boolean,
},
data() {
return {
message: "",
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 = 8;
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 (event) {
event.preventDefault();
if (!isMobile()) {
this.send()
}
}
},
}
</script>
<style scoped>
</style>

View File

@@ -0,0 +1,346 @@
<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 = computed(() => {
return settings.value && settings.value.open_api_key_setting && settings.value.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>
@@ -110,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>
@@ -127,18 +131,30 @@ 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"
> >
<div class="d-flex flex-row" :style="{ marginTop: '5px' }">
<div class="flex-grow-1">
<v-text-field
v-model="editingPrompt.title"
:loading="editingPrompt.updating"
:label="$t('titlePrompt')"
variant="underlined"
density="compact"
hide-details
>
</v-text-field>
<v-textarea <v-textarea
rows="2" rows="2"
v-model="editingPrompt.prompt" v-model="editingPrompt.prompt"
:loading="editingPrompt.updating" :loading="editingPrompt.updating"
variant="underlined" variant="underlined"
hide-details
density="compact" density="compact"
hide-details
> >
<template v-slot:append> </v-textarea>
</div>
<div>
<div class="d-flex flex-column"> <div class="d-flex flex-column">
<v-btn <v-btn
icon="done" icon="done"
@@ -154,8 +170,8 @@ onMounted( () => {
> >
</v-btn> </v-btn>
</div> </div>
</template> </div>
</v-textarea> </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"
@@ -163,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"
@@ -184,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"
> >
@@ -193,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"
@@ -211,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>

18
composables/fetch.js Normal file
View File

@@ -0,0 +1,18 @@
export const useMyFetch = (url, options = {}) => {
let defaultOptions = {
headers: {
Accept: '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,12 +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', () => {}) export const useSettings = () => useState('settings', () => {})
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
}

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,13 @@
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_APP_NAME='ChatGPT UI' # The name of the application
- NUXT_PUBLIC_TYPEWRITER=true # - NUXT_PUBLIC_TYPEWRITER=true # Whether to enable the typewriter effect, default false
- NUXT_PUBLIC_TYPEWRITER_DELAY=100 # - NUXT_PUBLIC_TYPEWRITER_DELAY=50 # The delay time of the typewriter effect, default 50ms
depends_on: depends_on:
- backend-web-server - backend-web-server
ports: ports:
@@ -15,9 +16,11 @@ 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
@@ -36,6 +39,7 @@ services:
- 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

View File

@@ -4,12 +4,27 @@
[English](../../README.md) | [中文](./docs/zh/README.md) [English](../../README.md) | [中文](./docs/zh/README.md)
用户指南: [https://wongsaang.github.io/chatgpt-ui-docs/zh/](https://wongsaang.github.io/chatgpt-ui-docs/zh/)
ChatGPT Web 客户端,支持多用户,支持 Mysql、PostgreSQL 等多种数据库连接进行数据持久化存储,支持多语言。提供 Docker 镜像和快速部署脚本。 ChatGPT Web 客户端,支持多用户,支持 Mysql、PostgreSQL 等多种数据库连接进行数据持久化存储,支持多语言。提供 Docker 镜像和快速部署脚本。
本项目的服务端:[https://github.com/WongSaang/chatgpt-ui-server](https://github.com/WongSaang/chatgpt-ui-server)
https://user-images.githubusercontent.com/46235412/227156264-ca17ab17-999b-414f-ab06-3f75b5235bfe.mp4 https://user-images.githubusercontent.com/46235412/227156264-ca17ab17-999b-414f-ab06-3f75b5235bfe.mp4
## 📢 更新 ## 📢 更新
<details open>
<summary><strong>2023-04-06</strong></summary>
客户端改成服务端渲染SSR的方式部署现在可以使用环境变量了可用环境变量请看下方 docker-compose 配置。提升了首屏加载速度,减少白屏时间。
</details>
<details open>
<summary><strong>2023-03-27</strong></summary>
🚀 支持 gpt-4 模型。你可以在前端的“模型参数”中选择模型gpt-4 模型需要通过 openai 的白名单才能使用。
</details>
<details open> <details open>
<summary><strong>2023-03-23</strong></summary> <summary><strong>2023-03-23</strong></summary>
增加网页搜索能力,使得 ChatGPT 生成的回答更与时俱进! 增加网页搜索能力,使得 ChatGPT 生成的回答更与时俱进!
@@ -23,17 +38,7 @@ https://user-images.githubusercontent.com/46235412/227156264-ca17ab17-999b-414f-
</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`
@@ -95,9 +100,9 @@ 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_APP_NAME='ChatGPT UI' # APP 名称
- NUXT_PUBLIC_TYPEWRITER=true # 是否启用打字机效果,默认关闭 # - NUXT_PUBLIC_TYPEWRITER=true # 是否开启 打字机 效果
- NUXT_PUBLIC_TYPEWRITER_DELAY=100 # 打字机效果的延迟时间,默认 50毫秒 # - NUXT_PUBLIC_TYPEWRITER_DELAY=50 # 打字机效果的延迟时间,单位:毫秒,默认50
depends_on: depends_on:
- backend-web-server - backend-web-server
ports: ports:
@@ -108,6 +113,7 @@ 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 # 默认超级用户

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",
@@ -34,8 +39,21 @@
"copied": "Copied", "copied": "Copied",
"delete": "Delete", "delete": "Delete",
"signOut": "Sign out", "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", "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]", "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: ",
"maxTokenTips1": "The maximum context length of the current model is",
"maxTokenTips2": "token, which includes the length of the prompt and the length of the generated text. The `Max Tokens` here refers to the length of the generated text. Therefore, you should leave some space for your prompt and not set it too large or to the maximum.",
"frugalMode": "Frugal mode",
"frugalModeTip": "Activate frugal mode, the client will not send historical messages to ChatGPT, which can save token consumption. If you want ChatGPT to understand the context of the conversation, please turn off frugal mode.",
"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.",

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

@@ -0,0 +1,79 @@
{
"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Содержание: ",
"maxTokenTips1": "The maximum context length of the current model is",
"maxTokenTips2": "token, which includes the length of the prompt and the length of the generated text. The `Max Tokens` here refers to the length of the generated text. Therefore, you should leave some space for your prompt and not set it too large or to the maximum.",
"frugalMode": "Frugal mode",
"frugalModeTip": "Activate frugal mode, the client will not send historical messages to ChatGPT, which can save token consumption. If you want ChatGPT to understand the context of the conversation, please turn off frugal mode.",
"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": "模型",
@@ -34,8 +39,21 @@
"copied": "已复制", "copied": "已复制",
"delete": "删除", "delete": "删除",
"signOut": "退出登录", "signOut": "退出登录",
"resetPassword": "重置密码",
"submit": "提交",
"agree": "同意",
"newPassword": "新密码",
"currentPassword": "当前密码",
"confirmPassword": "确认密码",
"yourPasswordHasBeenReset": "您的密码已重置",
"nowYouNeedToSignInAgain": "现在您需要再次登录",
"webSearch": "网页搜索", "webSearch": "网页搜索",
"webSearchDefaultPrompt": "网络搜索结果:\n\n[web_results]\n当前日期[current_date]\n\n说明使用提供的网络搜索结果对给定的查询写出全面的回复。确保在引用参考文献后使用 [[number](URL)] 符号进行引用结果. 如果提供的搜索结果涉及到多个具有相同名称的主题,请针对每个主题编写单独的答案。\n查询[query]", "webSearchDefaultPrompt": "网络搜索结果:\n\n[web_results]\n当前日期[current_date]\n\n说明使用提供的网络搜索结果对给定的查询写出全面的回复。确保在引用参考文献后使用 [[number](URL)] 符号进行引用结果. 如果提供的搜索结果涉及到多个具有相同名称的主题,请针对每个主题编写单独的答案。\n查询[query]",
"genTitlePrompt": "为以下内容生成一个不超过10个字的简短标题。 \n\n内容: ",
"maxTokenTips1": "当前模型的最大上下文长度为",
"maxTokenTips2": "个 token它包括了指令的长度和生成的文本长度。此处的最大 token 数量是指生成的文本长度。所以您应该为您的指令预留一些空间,不宜设置过大或拉满。",
"frugalMode": "节俭模式",
"frugalModeTip": "开启节俭模式客户端不会把历史消息发送给ChatGPT可以节省 token 的消耗。如果你想让 ChatGPT 了解对话的上下文,请关闭节俭模式。",
"welcomeScreen": { "welcomeScreen": {
"introduction1": "是一个非官方的ChatGPT客户端但使用OpenAI的官方API", "introduction1": "是一个非官方的ChatGPT客户端但使用OpenAI的官方API",
"introduction2": "在使用本客户端之前您需要一个OpenAI API密钥。", "introduction2": "在使用本客户端之前您需要一个OpenAI API密钥。",

View File

@@ -1,328 +1,8 @@
<script setup>
import {useDisplay} from "vuetify";
const { $i18n, $auth } = 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
})
const signOut = async () => {
const { data, error } = await useFetch('/api/account/logout/', {
method: 'POST'
})
if (!error.value) {
await $auth.logout()
}
}
onMounted(async () => {
loadConversations()
loadSettings()
})
</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>
<ApiKeyDialog
v-if="runtimeConfig.public.customApiKey"
/>
<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-item
rounded="xl"
prepend-icon="logout"
:title="$t('signOut')"
@click="signOut"
></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-app-bar>
<v-main>
<NuxtPage/>
</v-main>
</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;
}
</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

@@ -2,8 +2,8 @@
const appName = process.env.NUXT_PUBLIC_APP_NAME ?? '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,
@@ -28,7 +28,7 @@ export default defineNuxtConfig({
modules: [ modules: [
'@kevinmarrec/nuxt-pwa', '@kevinmarrec/nuxt-pwa',
'@nuxtjs/color-mode', '@nuxtjs/color-mode',
'@nuxtjs/i18n', '@nuxtjs/i18n'
], ],
pwa: { pwa: {
manifest: { manifest: {
@@ -54,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,
@@ -62,15 +68,5 @@ export default defineNuxtConfig({
vueI18n: { vueI18n: {
fallbackLocale: 'en', fallbackLocale: 'en',
}, },
},
nitro: {
devProxy: {
"/api": {
target: process.env.NUXT_DEV_SERVER ?? 'http://localhost:8000/api',
prependPath: true,
changeOrigin: true,
} }
}
},
}) })

View File

@@ -11,14 +11,14 @@
"@kevinmarrec/nuxt-pwa": "^0.17.0", "@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",
"@vite-pwa/nuxt": "^0.0.7",
"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",
"copy-to-clipboard": "^3.3.3", "copy-to-clipboard": "^3.3.3",
"highlight.js": "^11.7.0", "highlight.js": "^11.7.0",
"http-proxy-middleware": "3.0.0-beta.1",
"is-mobile": "^3.1.1", "is-mobile": "^3.1.1",
"markdown-it": "^13.0.1", "markdown-it": "^13.0.1",
"nanoid": "^4.0.1", "nanoid": "^4.0.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
@@ -54,7 +55,7 @@ onNuxtReady(() => {
<div v-else> <div v-else>
<h2 class="text-h4">Verify your email</h2> <h2 class="text-h4">Verify your email</h2>
<p class="mt-5"> <p class="mt-5">
We've sent a verification email to <strong>{{ $auth.user.email }}</strong>. <br> We've sent a verification email to <strong>{{ user.email }}</strong>. <br>
Please check your inbox and click the link to verify your email address. Please check your inbox and click the link to verify your email address.
</p> </p>
<p v-if="errorMsg" <p v-if="errorMsg"

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,7 +72,7 @@ const submit = async () => {
} }
} }
} else { } else {
$auth.setUser(data.value.user) setUser(data.value.user)
navigateTo('/account/onboarding?email_verification_required='+data.value.email_verification_required) navigateTo('/account/onboarding?email_verification_required='+data.value.email_verification_required)
} }
@@ -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,267 +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'
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) => {
ctrl = new AbortController()
let webSearchParams = {} const createNewConversation = () => {
if (enableWebSearch.value) { if (route.path !== '/') {
webSearchParams['web_search'] = { return navigateTo('/?new')
ua: navigator.userAgent,
default_prompt: $i18n.t('webSearchDefaultPrompt')
} }
} conversation.value = Object.assign(getDefaultConversationData(), {
topic: $i18n.t('newConversation')
const data = Object.assign({}, currentModel.value, {
openaiApiKey: openaiApiKey.value,
message: message,
conversationId: currentConversation.value.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) {
// console.log(message)
const event = message.event
const data = JSON.parse(message.data)
if (event === 'error') {
throw new Error(data.error);
}
if (event === 'userMessageId') {
currentConversation.value.messages[currentConversation.value.messages.length - 1].id = data.userMessageId
return;
}
if (event === 'done') {
if (currentConversation.value.id === null) {
currentConversation.value.id = data.conversationId
genTitle(currentConversation.value.id)
}
currentConversation.value.messages[currentConversation.value.messages.length - 1].id = data.messageId
abortFetch()
return;
}
messageQueue.push(data.content)
processMessageQueue()
scrollChatWindow()
},
}) })
} catch (err) { }
console.log(err)
abortFetch()
showSnackbar(err.message) onMounted(async () => {
if (route.params.id) {
conversation.value.loadingMessages = true
await loadConversation()
await loadMessage()
conversation.value.loadingMessages = false
} }
} })
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()
currentConversation.value.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) => {
currentConversation.value.messages.splice(index, 1)
}
const showWebSearchToggle = ref(false)
const enableWebSearch = 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'
} }
}) })
</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-toolbar-title>{{ navTitle }}</v-toolbar-title>
<v-container>
<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"
: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>
<Welcome v-else />
<v-footer app>
<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-spacer></v-spacer>
</v-toolbar>
<!-- <div class="py-2 text-disabled text-caption font-weight-light text-center">-->
<!-- © {{ new Date().getFullYear() }} {{ runtimeConfig.public.appName }}-->
<!-- </div>-->
</div>
</v-footer>
<v-snackbar
v-model="snackbar"
multi-line
location="top"
>
{{ snackbarText }}
<template v-slot:actions>
<v-btn <v-btn
color="red" :title="$t('newConversation')"
variant="text" icon="add"
@click="snackbar = false" @click="createNewConversation"
class="d-md-none"
></v-btn>
<v-btn
variant="outlined"
class="text-none d-none d-md-block"
@click="createNewConversation"
> >
Close {{ $t('newConversation') }}
</v-btn> </v-btn>
</template>
</v-snackbar> </v-app-bar>
<v-main>
<Welcome v-if="!route.params.id && conversation.messages.length === 0" />
<Conversation :conversation="conversation" />
</v-main>
</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
}
}
})

View File

@@ -0,0 +1,14 @@
import { createProxyMiddleware } from 'http-proxy-middleware'
export default defineEventHandler(async (event) => {
await new Promise((resolve, reject) => {
createProxyMiddleware({
target: process.env.SERVER_DOMAIN,
pathFilter: '/api',
})(event.node.req, event.node.res, (err) => {
if (err)
reject(err)
else
resolve(true)
})
})
})

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 = {
'gpt-3.5-turbo': {
name: 'gpt-3.5-turbo', name: 'gpt-3.5-turbo',
frequency_penalty: 0.0, frequency_penalty: 0.0,
presence_penalty: 0.0, presence_penalty: 0.0,
total_tokens: 4096,
max_tokens: 1000, max_tokens: 1000,
temperature: 0.7, temperature: 0.7,
top_p: 1.0 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,37 +17,28 @@ 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
@@ -61,12 +53,27 @@ const transformData = (list) => {
return result; return result;
} }
export const loadSettings = async () => { export const fetchSystemSettings = async () => {
const settings = useSettings()
const { data, error } = await useAuthFetch('/api/chat/settings/', { const { data, error } = await useAuthFetch('/api/chat/settings/', {
method: 'GET' method: 'GET',
}) })
if (!error.value) { if (!error.value) {
const settings = useSettings()
settings.value = transformData(data.value) settings.value = transformData(data.value)
} }
} }
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
} }

3869
yarn.lock

File diff suppressed because it is too large Load Diff