Compare commits

...

36 Commits

Author SHA1 Message Date
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 2254 additions and 3892 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

@@ -6,10 +6,24 @@
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 +39,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 +102,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 +115,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

17
app.vue
View File

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

294
components/Conversation.vue Normal file
View File

@@ -0,0 +1,294 @@
<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 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"
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

@@ -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(() => {
saveCurrentModel(newVal) currentModel.value = getCurrentModel()
}, { deep: true }) watch(currentModel, (newVal, oldVal) => {
currentModelDefault.value = MODELS[newVal.name]
saveCurrentModel(newVal)
}, { 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 = () => {
@@ -49,10 +51,7 @@ const bindCopyCodeToButtons = () => {
} }
onMounted(() => { onMounted(() => {
bindCopyCodeToButtons() console.log('mounted')
})
onUpdated(() => {
bindCopyCodeToButtons() bindCopyCodeToButtons()
}) })
@@ -64,18 +63,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 pa-3"
class="chat-msg-content" ></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,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"
@@ -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>

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,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

@@ -6,10 +6,23 @@
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 +36,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 +98,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 +111,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,19 @@
"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.",
"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.",

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

@@ -0,0 +1,77 @@
{
"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.",
"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,19 @@
"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 数量是指生成的文本长度。所以您应该为您的指令预留一些空间,不宜设置过大或拉满。",
"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,9 +11,8 @@
"@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",

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 = {}
if (enableWebSearch.value) {
webSearchParams['web_search'] = {
ua: navigator.userAgent,
default_prompt: $i18n.t('webSearchDefaultPrompt')
}
}
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)
} }
} }
const currentConversation = useConversion() const createNewConversation = () => {
if (route.path !== '/') {
const grab = ref(null) return navigateTo('/?new')
const scrollChatWindow = () => {
if (grab.value === null) {
return;
} }
grab.value.scrollIntoView({behavior: 'smooth'}) conversation.value = Object.assign(getDefaultConversationData(), {
topic: $i18n.t('newConversation')
})
} }
const send = (message) => { onMounted(async () => {
fetchingResponse.value = true if (route.params.id) {
currentConversation.value.messages.push({message: message}) conversation.value.loadingMessages = true
fetchReply(message) await loadConversation()
scrollChatWindow() await loadMessage()
} conversation.value.loadingMessages = false
const stop = () => { }
abortFetch() })
}
const snackbar = ref(false)
const snackbarText = ref('')
const showSnackbar = (text) => {
snackbarText.value = text
snackbar.value = true
}
const editor = ref(null) const navTitle = computed(() => {
const usePrompt = (prompt) => { if (conversation.value && conversation.value.topic !== null) {
editor.value.usePrompt(prompt) return conversation.value.topic === '' ? $i18n.t('defaultConversationTitle') : conversation.value.topic
} }
return runtimeConfig.public.appName
})
const deleteMessage = (index) => { onActivated(async () => {
currentConversation.value.messages.splice(index, 1) if (route.path === '/' && route.query.new !== undefined) {
} createNewConversation()
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-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> <v-toolbar-title>{{ navTitle }}</v-toolbar-title>
</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-toolbar>
<!-- <div class="py-2 text-disabled text-caption font-weight-light text-center">--> <v-spacer></v-spacer>
<!-- © {{ 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 :title="$t('newConversation')"
color="red" icon="add"
variant="text" @click="createNewConversation"
@click="snackbar = false" class="d-md-none"
> ></v-btn>
Close <v-btn
</v-btn> variant="outlined"
</template> class="text-none d-none d-md-block"
</v-snackbar> @click="createNewConversation"
>
{{ $t('newConversation') }}
</v-btn>
</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,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,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
} }

3846
yarn.lock

File diff suppressed because it is too large Load Diff