Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8a9b705b99 | ||
|
|
82c1811034 | ||
|
|
0d6aef6872 | ||
|
|
3f3ab8c33b | ||
|
|
6522536291 | ||
|
|
2bca5a032c | ||
|
|
53460bd891 | ||
|
|
fb9e8b8c7d | ||
|
|
21dc2b9236 | ||
|
|
1a6bf1d239 | ||
|
|
3e3283029d | ||
|
|
16c9b0e230 | ||
|
|
836df995d0 | ||
|
|
5b9d52b177 |
16
Dockerfile
16
Dockerfile
@@ -4,19 +4,23 @@ WORKDIR /app
|
||||
|
||||
COPY package.json yarn.lock ./
|
||||
|
||||
RUN yarn install
|
||||
RUN yarn install && yarn cache clean
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN yarn generate
|
||||
RUN yarn build
|
||||
|
||||
|
||||
FROM nginx:alpine
|
||||
FROM node:18-alpine3.16
|
||||
|
||||
ENV NITRO_PORT=80
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=builder /app/.output/public .
|
||||
|
||||
COPY nginx.conf /etc/nginx/templates/default.conf.template
|
||||
COPY --from=builder /app/.output/ .
|
||||
|
||||
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"]
|
||||
10
README.md
10
README.md
@@ -6,11 +6,18 @@
|
||||
|
||||
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
|
||||
|
||||
|
||||
## 📢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.
|
||||
@@ -95,6 +102,9 @@ services:
|
||||
image: wongsaang/chatgpt-ui-client:latest
|
||||
environment:
|
||||
- SERVER_DOMAIN=http://backend-web-server
|
||||
# - NUXT_PUBLIC_APP_NAME='ChatGPT UI' # The name of the application
|
||||
# - NUXT_PUBLIC_TYPEWRITER=true # Whether to enable the typewriter effect, default false
|
||||
# - NUXT_PUBLIC_TYPEWRITER_DELAY=50 # The delay time of the typewriter effect, default 50ms
|
||||
depends_on:
|
||||
- backend-web-server
|
||||
ports:
|
||||
|
||||
17
app.vue
17
app.vue
@@ -1,8 +1,15 @@
|
||||
<script setup>
|
||||
onNuxtReady(() => {
|
||||
fetchSystemSettings()
|
||||
// api key
|
||||
const apiKey = useApiKey()
|
||||
apiKey.value = getStoredApiKey()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<NuxtLayout>
|
||||
<NuxtLoadingIndicator />
|
||||
<NuxtLayout>
|
||||
<NuxtPage />
|
||||
</NuxtLayout>
|
||||
</div>
|
||||
<NuxtPage />
|
||||
</NuxtLayout>
|
||||
</template>
|
||||
@@ -1,8 +1,7 @@
|
||||
<script setup>
|
||||
import {EventStreamContentType, fetchEventSource} from '@microsoft/fetch-event-source'
|
||||
import {addConversation} from "../utils/helper";
|
||||
|
||||
const { $i18n, $auth } = useNuxtApp()
|
||||
const { $i18n } = useNuxtApp()
|
||||
const runtimeConfig = useRuntimeConfig()
|
||||
const currentModel = useCurrentModel()
|
||||
const openaiApiKey = useApiKey()
|
||||
@@ -108,12 +107,12 @@ const fetchReply = async (message) => {
|
||||
}
|
||||
|
||||
if (event === 'done') {
|
||||
if (props.conversation.id === null) {
|
||||
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)
|
||||
}
|
||||
props.conversation.messages[props.conversation.messages.length - 1].id = data.messageId
|
||||
abortFetch()
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -138,12 +137,6 @@ const scrollChatWindow = () => {
|
||||
grab.value.scrollIntoView({behavior: 'smooth'})
|
||||
}
|
||||
|
||||
const checkOrAddConversation = () => {
|
||||
if (props.conversation.messages.length === 0) {
|
||||
props.conversation.messages.push({id: null, is_bot: true, message: ''})
|
||||
}
|
||||
}
|
||||
|
||||
const send = (message) => {
|
||||
fetchingResponse.value = true
|
||||
if (props.conversation.messages.length === 0) {
|
||||
@@ -173,74 +166,80 @@ const deleteMessage = (index) => {
|
||||
props.conversation.messages.splice(index, 1)
|
||||
}
|
||||
|
||||
const showWebSearchToggle = ref(false)
|
||||
const enableWebSearch = ref(false)
|
||||
const enableCustomApiKey = ref(false)
|
||||
|
||||
const settings = useSettings()
|
||||
const enableWebSearch = ref(false)
|
||||
|
||||
watchEffect(() => {
|
||||
if (settings.value) {
|
||||
const settingsValue = toRaw(settings.value)
|
||||
showWebSearchToggle.value = settingsValue.open_web_search && settingsValue.open_web_search === 'True'
|
||||
enableCustomApiKey.value = settingsValue.open_api_key_setting && settingsValue.open_api_key_setting === 'True'
|
||||
}
|
||||
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.loadingMessages"
|
||||
class="text-center"
|
||||
>
|
||||
<v-progress-circular
|
||||
indeterminate
|
||||
color="primary"
|
||||
></v-progress-circular>
|
||||
</div>
|
||||
<div v-else>
|
||||
<div v-if="conversation">
|
||||
<div
|
||||
v-if="conversation.messages.length > 0"
|
||||
ref="chatWindow"
|
||||
v-if="conversation.loadingMessages"
|
||||
class="text-center"
|
||||
>
|
||||
<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>
|
||||
<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>
|
||||
<Welcome v-if="conversation.id === null && conversation.messages.length === 0" />
|
||||
</div>
|
||||
|
||||
|
||||
<v-footer app>
|
||||
<v-footer
|
||||
app
|
||||
class="footer"
|
||||
>
|
||||
<div class="px-md-16 w-100 d-flex flex-column">
|
||||
<div class="d-flex align-center">
|
||||
<v-btn
|
||||
@@ -287,3 +286,9 @@ watchEffect(() => {
|
||||
</v-snackbar>
|
||||
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.footer {
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -8,10 +8,13 @@ const availableModels = [
|
||||
]
|
||||
const currentModelDefault = ref(MODELS[currentModel.value.name])
|
||||
|
||||
watch(currentModel, (newVal, oldVal) => {
|
||||
currentModelDefault.value = MODELS[newVal.name]
|
||||
saveCurrentModel(newVal)
|
||||
}, { deep: true })
|
||||
onNuxtReady(() => {
|
||||
currentModel.value = getCurrentModel()
|
||||
watch(currentModel, (newVal, oldVal) => {
|
||||
currentModelDefault.value = MODELS[newVal.name]
|
||||
saveCurrentModel(newVal)
|
||||
}, { deep: true })
|
||||
})
|
||||
|
||||
</script>
|
||||
|
||||
@@ -53,7 +56,7 @@ watch(currentModel, (newVal, oldVal) => {
|
||||
<div class="d-flex justify-space-between align-center">
|
||||
<v-list-subheader>{{ $t('temperature') }}</v-list-subheader>
|
||||
<v-text-field
|
||||
v-model="currentModel.temperature"
|
||||
v-model.number="currentModel.temperature"
|
||||
hide-details
|
||||
single-line
|
||||
density="compact"
|
||||
@@ -82,7 +85,7 @@ watch(currentModel, (newVal, oldVal) => {
|
||||
<div class="d-flex justify-space-between align-center">
|
||||
<v-list-subheader>{{ $t('maxTokens') }}</v-list-subheader>
|
||||
<v-text-field
|
||||
v-model="currentModel.max_tokens"
|
||||
v-model.number="currentModel.max_tokens"
|
||||
hide-details
|
||||
single-line
|
||||
density="compact"
|
||||
@@ -93,6 +96,9 @@ watch(currentModel, (newVal, oldVal) => {
|
||||
class="flex-grow-0"
|
||||
></v-text-field>
|
||||
</div>
|
||||
<div class="text-caption">
|
||||
{{ $t('maxTokenTips1') }} <b>{{ currentModelDefault.total_tokens }}</b> {{ $t('maxTokenTips2') }}
|
||||
</div>
|
||||
</v-col>
|
||||
<v-col cols="12">
|
||||
<v-slider
|
||||
@@ -111,7 +117,7 @@ watch(currentModel, (newVal, oldVal) => {
|
||||
<div class="d-flex justify-space-between align-center">
|
||||
<v-list-subheader>{{ $t('topP') }}</v-list-subheader>
|
||||
<v-text-field
|
||||
v-model="currentModel.top_p"
|
||||
v-model.number="currentModel.top_p"
|
||||
hide-details
|
||||
single-line
|
||||
density="compact"
|
||||
@@ -138,7 +144,7 @@ watch(currentModel, (newVal, oldVal) => {
|
||||
<div class="d-flex justify-space-between align-center">
|
||||
<v-list-subheader>{{ $t('frequencyPenalty') }}</v-list-subheader>
|
||||
<v-text-field
|
||||
v-model="currentModel.frequency_penalty"
|
||||
v-model.number="currentModel.frequency_penalty"
|
||||
hide-details
|
||||
single-line
|
||||
density="compact"
|
||||
@@ -164,7 +170,7 @@ watch(currentModel, (newVal, oldVal) => {
|
||||
<div class="d-flex justify-space-between align-center">
|
||||
<v-list-subheader>{{ $t('presencePenalty') }}</v-list-subheader>
|
||||
<v-text-field
|
||||
v-model="currentModel.presence_penalty"
|
||||
v-model.number="currentModel.presence_penalty"
|
||||
hide-details
|
||||
single-line
|
||||
density="compact"
|
||||
@@ -23,8 +23,10 @@ const contentHtml = ref('')
|
||||
|
||||
const contentElm = ref(null)
|
||||
|
||||
watchEffect(() => {
|
||||
watchEffect(async () => {
|
||||
contentHtml.value = props.message.message ? md.render(props.message.message) : ''
|
||||
await nextTick()
|
||||
bindCopyCodeToButtons()
|
||||
})
|
||||
|
||||
const bindCopyCodeToButtons = () => {
|
||||
@@ -49,10 +51,7 @@ const bindCopyCodeToButtons = () => {
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
bindCopyCodeToButtons()
|
||||
})
|
||||
|
||||
onUpdated(() => {
|
||||
console.log('mounted')
|
||||
bindCopyCodeToButtons()
|
||||
})
|
||||
|
||||
|
||||
346
components/NavigationDrawer.vue
Normal file
346
components/NavigationDrawer.vue
Normal 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>
|
||||
@@ -86,7 +86,7 @@ const selectPrompt = (prompt) => {
|
||||
menu.value = false
|
||||
}
|
||||
|
||||
onMounted( () => {
|
||||
onNuxtReady( () => {
|
||||
loadPrompts()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -4,10 +4,9 @@
|
||||
<v-col cols="12">
|
||||
<div class="text-center">
|
||||
<h2 class="text-h2">{{ $t('welcomeTo') }} <span class="text-primary">{{ runtimeConfig.public.appName }}</span></h2>
|
||||
<p class="text-caption mt-5">
|
||||
<p class="text-caption my-5">
|
||||
{{ runtimeConfig.public.appName }} {{ $t('welcomeScreen.introduction1') }}
|
||||
<br>
|
||||
{{ $t('welcomeScreen.introduction2') }}
|
||||
</p>
|
||||
</div>
|
||||
</v-col>
|
||||
|
||||
19
composables/fetch.js
Normal file
19
composables/fetch.js
Normal 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
|
||||
}
|
||||
@@ -5,8 +5,10 @@ export const useCurrentModel = () => useState('currentModel', () => getCurrentMo
|
||||
|
||||
export const useApiKey = () => useState('apiKey', () => getStoredApiKey())
|
||||
|
||||
export const useConversation = () => useState('conversation', () => getDefaultConversationData())
|
||||
|
||||
export const useConversations = () => useState('conversations', () => [])
|
||||
|
||||
export const useSettings = () => useState('settings', () => {})
|
||||
|
||||
export const useUser = () => useState('user', () => null)
|
||||
|
||||
export const useDrawer = () => useState('drawer', () => false)
|
||||
@@ -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
|
||||
}
|
||||
@@ -4,13 +4,14 @@ services:
|
||||
platform: linux/x86_64
|
||||
build: .
|
||||
environment:
|
||||
SERVER_DOMAIN: http://web-server
|
||||
SERVER_DOMAIN: ${SERVER_DOMAIN:-http://web-server}
|
||||
NUXT_PUBLIC_TYPEWRITER: false
|
||||
ports:
|
||||
- '${CLIENT_PORT:-8080}:80'
|
||||
- '${CLIENT_PORT:-80}:80'
|
||||
networks:
|
||||
- chatgpt_network
|
||||
restart: always
|
||||
|
||||
networks:
|
||||
chatgpt_network:
|
||||
external: True
|
||||
driver: bridge
|
||||
|
||||
16
docker-compose.test.yml
Normal file
16
docker-compose.test.yml
Normal 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
|
||||
@@ -5,6 +5,9 @@ services:
|
||||
image: wongsaang/chatgpt-ui-client:latest
|
||||
environment:
|
||||
- SERVER_DOMAIN=http://backend-web-server
|
||||
# - NUXT_PUBLIC_APP_NAME='ChatGPT UI' # The name of the application
|
||||
# - NUXT_PUBLIC_TYPEWRITER=true # Whether to enable the typewriter effect, default false
|
||||
# - NUXT_PUBLIC_TYPEWRITER_DELAY=50 # The delay time of the typewriter effect, default 50ms
|
||||
depends_on:
|
||||
- backend-web-server
|
||||
ports:
|
||||
|
||||
@@ -6,11 +6,18 @@
|
||||
|
||||
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
|
||||
|
||||
|
||||
## 📢 更新
|
||||
|
||||
<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 的白名单才能使用。
|
||||
@@ -91,6 +98,9 @@ services:
|
||||
image: wongsaang/chatgpt-ui-client:latest
|
||||
environment:
|
||||
- SERVER_DOMAIN=http://backend-web-server
|
||||
# - NUXT_PUBLIC_APP_NAME='ChatGPT UI' # APP 名称
|
||||
# - NUXT_PUBLIC_TYPEWRITER=true # 是否开启 打字机 效果
|
||||
# - NUXT_PUBLIC_TYPEWRITER_DELAY=50 # 打字机效果的延迟时间,单位:毫秒,默认:50
|
||||
depends_on:
|
||||
- backend-web-server
|
||||
ports:
|
||||
|
||||
@@ -50,6 +50,8 @@
|
||||
"webSearch": "Web Search",
|
||||
"webSearchDefaultPrompt": "Web search results:\n\n[web_results]\nCurrent date: [current_date]\n\nInstructions: Using the provided web search results, write a comprehensive reply to the given query. Make sure to cite results using [[number](URL)] notation after the reference. If the provided search results refer to multiple subjects with the same name, write separate answers for each subject.\nQuery: [query]",
|
||||
"genTitlePrompt": "Generate a short title for the following content, no more than 10 words. \n\nContent: ",
|
||||
"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": "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.",
|
||||
|
||||
@@ -50,6 +50,8 @@
|
||||
"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, прежде чем вы сможете использовать этот клиент.",
|
||||
|
||||
@@ -50,6 +50,8 @@
|
||||
"webSearch": "网页搜索",
|
||||
"webSearchDefaultPrompt": "网络搜索结果:\n\n[web_results]\n当前日期:[current_date]\n\n说明:使用提供的网络搜索结果,对给定的查询写出全面的回复。确保在引用参考文献后使用 [[number](URL)] 符号进行引用结果. 如果提供的搜索结果涉及到多个具有相同名称的主题,请针对每个主题编写单独的答案。\n查询:[query]",
|
||||
"genTitlePrompt": "为以下内容生成一个不超过10个字的简短标题。 \n\n内容: ",
|
||||
"maxTokenTips1": "当前模型的最大上下文长度为",
|
||||
"maxTokenTips2": "个 token,它包括了指令的长度和生成的文本长度。此处的最大 token 数量是指生成的文本长度。所以您应该为您的指令预留一些空间,不宜设置过大或拉满。",
|
||||
"welcomeScreen": {
|
||||
"introduction1": "是一个非官方的ChatGPT客户端,但使用OpenAI的官方API",
|
||||
"introduction2": "在使用本客户端之前,您需要一个OpenAI API密钥。",
|
||||
|
||||
@@ -1,372 +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 = useConversations()
|
||||
const currentConversation = useConversation()
|
||||
|
||||
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) {
|
||||
createNewConversation()
|
||||
}
|
||||
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 getConversations()
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
const settings = useSettings()
|
||||
const showApiKeySetting = ref(false)
|
||||
watchEffect(() => {
|
||||
if (settings.value) {
|
||||
const settingsValue = toRaw(settings.value)
|
||||
showApiKeySetting.value = settingsValue.open_api_key_setting && settingsValue.open_api_key_setting === 'True'
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
loadConversations()
|
||||
loadSettings()
|
||||
})
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-app
|
||||
:theme="$colorMode.value"
|
||||
>
|
||||
<v-navigation-drawer
|
||||
v-model="drawer"
|
||||
:permanent="drawerPermanent"
|
||||
width="300"
|
||||
>
|
||||
<template
|
||||
v-slot:prepend
|
||||
>
|
||||
<v-list>
|
||||
<v-list-item
|
||||
:title="$auth.user.username"
|
||||
:subtitle="$auth.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}` : undefined"
|
||||
v-bind="props"
|
||||
>
|
||||
<v-list-item-title>{{ 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"
|
||||
:prepend-icon="$colorMode.value === 'light' ? 'light_mode' : 'dark_mode'"
|
||||
:title="$t('themeMode')"
|
||||
></v-list-item>
|
||||
</template>
|
||||
<v-list
|
||||
bg-color="white"
|
||||
>
|
||||
<v-list-item
|
||||
v-for="(theme, idx) in themes"
|
||||
:key="idx"
|
||||
@click="setTheme(theme.value)"
|
||||
>
|
||||
<v-list-item-title>{{ theme.title }}</v-list-item-title>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-menu>
|
||||
|
||||
<SettingsLanguages/>
|
||||
|
||||
<v-list-item
|
||||
rounded="xl"
|
||||
prepend-icon="help_outline"
|
||||
:title="$t('feedback')"
|
||||
@click="feedback"
|
||||
></v-list-item>
|
||||
|
||||
</v-list>
|
||||
</div>
|
||||
</template>
|
||||
</v-navigation-drawer>
|
||||
|
||||
<v-app-bar
|
||||
class=""
|
||||
>
|
||||
<v-app-bar-nav-icon @click="drawer = !drawer"></v-app-bar-nav-icon>
|
||||
|
||||
<v-toolbar-title>{{ currentConversation.id ? currentConversation.topic : runtimeConfig.public.appName }}</v-toolbar-title>
|
||||
|
||||
<v-spacer></v-spacer>
|
||||
|
||||
<v-btn
|
||||
:title="$t('newConversation')"
|
||||
icon="add"
|
||||
@click="createNewConversation"
|
||||
class="d-md-none"
|
||||
></v-btn>
|
||||
<v-btn
|
||||
variant="outlined"
|
||||
class="text-none d-none d-md-block"
|
||||
@click="createNewConversation"
|
||||
>
|
||||
{{ $t('newConversation') }}
|
||||
</v-btn>
|
||||
|
||||
</v-app-bar>
|
||||
|
||||
<v-main>
|
||||
<NuxtPage/>
|
||||
</v-main>
|
||||
|
||||
<NavigationDrawer />
|
||||
<slot />
|
||||
</v-app>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.v-navigation-drawer__content::-webkit-scrollbar {
|
||||
width: 0;
|
||||
}
|
||||
.v-navigation-drawer__content:hover::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
.v-navigation-drawer__content:hover::-webkit-scrollbar-thumb {
|
||||
background-color: #999;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
</style>
|
||||
18
middleware/auth.ts
Normal file
18
middleware/auth.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -2,8 +2,8 @@
|
||||
const appName = process.env.NUXT_PUBLIC_APP_NAME ?? 'ChatGPT UI'
|
||||
|
||||
export default defineNuxtConfig({
|
||||
dev: false,
|
||||
ssr: false,
|
||||
debug: process.env.NODE_ENV !== 'production',
|
||||
ssr: true,
|
||||
app: {
|
||||
head: {
|
||||
title: appName,
|
||||
@@ -28,7 +28,7 @@ export default defineNuxtConfig({
|
||||
modules: [
|
||||
'@kevinmarrec/nuxt-pwa',
|
||||
'@nuxtjs/color-mode',
|
||||
'@nuxtjs/i18n',
|
||||
'@nuxtjs/i18n'
|
||||
],
|
||||
pwa: {
|
||||
manifest: {
|
||||
@@ -68,15 +68,5 @@ export default defineNuxtConfig({
|
||||
vueI18n: {
|
||||
fallbackLocale: 'en',
|
||||
},
|
||||
},
|
||||
nitro: {
|
||||
devProxy: {
|
||||
"/api": {
|
||||
target: process.env.NUXT_DEV_SERVER ?? 'http://localhost:8000/api',
|
||||
prependPath: true,
|
||||
changeOrigin: true,
|
||||
}
|
||||
|
||||
}
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
"@nuxtjs/color-mode": "^3.2.0",
|
||||
"@nuxtjs/i18n": "^8.0.0-beta.9",
|
||||
"material-design-icons-iconfont": "^6.7.0",
|
||||
"nuxt": "^3.2.0"
|
||||
"nuxt": "^3.3.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"@microsoft/fetch-event-source": "^2.0.1",
|
||||
|
||||
@@ -7,6 +7,7 @@ const route = useRoute()
|
||||
const sending = ref(false)
|
||||
const resent = ref(false)
|
||||
const errorMsg = ref(null)
|
||||
const user = useUser()
|
||||
const resendEmail = async () => {
|
||||
errorMsg.value = null
|
||||
sending.value = true
|
||||
@@ -54,7 +55,7 @@ onNuxtReady(() => {
|
||||
<div v-else>
|
||||
<h2 class="text-h4">Verify your email</h2>
|
||||
<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.
|
||||
</p>
|
||||
<p v-if="errorMsg"
|
||||
|
||||
@@ -24,7 +24,6 @@ const fieldErrors = ref({
|
||||
new_password1: '',
|
||||
new_password2: '',
|
||||
})
|
||||
const { $auth } = useNuxtApp()
|
||||
const errorMsg = ref(null)
|
||||
const resetForm = ref(null)
|
||||
const valid = ref(true)
|
||||
@@ -37,7 +36,7 @@ const signOut = async () => {
|
||||
method: 'POST'
|
||||
})
|
||||
if (!error.value) {
|
||||
await $auth.logout()
|
||||
await logout()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -67,6 +67,8 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {useUser} from "~/composables/states";
|
||||
|
||||
definePageMeta({
|
||||
layout: 'vuetify-app'
|
||||
})
|
||||
@@ -82,10 +84,8 @@ const formRules = ref({
|
||||
v => !!v || 'Password is required'
|
||||
]
|
||||
})
|
||||
const { $auth } = useNuxtApp()
|
||||
const errorMsg = ref(null)
|
||||
const signInForm = ref(null)
|
||||
const valid = ref(true)
|
||||
const submitting = ref(false)
|
||||
const route = useRoute()
|
||||
const passwordInputType = ref('password')
|
||||
@@ -99,6 +99,7 @@ const submit = async () => {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(formData.value)
|
||||
})
|
||||
submitting.value = false
|
||||
if (error.value) {
|
||||
if (error.value.status === 400) {
|
||||
if (error.value.data.non_field_errors) {
|
||||
@@ -108,10 +109,10 @@ const submit = async () => {
|
||||
errorMsg.value = 'Something went wrong. Please try again.'
|
||||
}
|
||||
} else {
|
||||
$auth.setUser(data.value.user)
|
||||
navigateTo(route.query.callback || '/')
|
||||
setUser(data.value.user)
|
||||
const callback = route.query.callback ? decodeURIComponent(route.query.callback) : '/'
|
||||
await navigateTo(callback)
|
||||
}
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,8 +3,6 @@ definePageMeta({
|
||||
layout: 'vuetify-app'
|
||||
})
|
||||
|
||||
const { $auth } = useNuxtApp()
|
||||
|
||||
const formData = ref({
|
||||
username: '',
|
||||
email: '',
|
||||
@@ -74,7 +72,7 @@ const submit = async () => {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
$auth.setUser(data.value.user)
|
||||
setUser(data.value.user)
|
||||
navigateTo('/account/onboarding?email_verification_required='+data.value.email_verification_required)
|
||||
}
|
||||
|
||||
|
||||
@@ -4,8 +4,11 @@ definePageMeta({
|
||||
path: '/:id?',
|
||||
keepalive: true
|
||||
})
|
||||
|
||||
const { $i18n } = useNuxtApp()
|
||||
const runtimeConfig = useRuntimeConfig()
|
||||
const drawer = useDrawer()
|
||||
const route = useRoute()
|
||||
const currentConversation = useConversation()
|
||||
const conversation = ref(getDefaultConversationData())
|
||||
|
||||
const loadConversation = async () => {
|
||||
@@ -22,20 +25,67 @@ const loadMessage = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
onActivated(async () => {
|
||||
const createNewConversation = () => {
|
||||
if (route.path !== '/') {
|
||||
return navigateTo('/?new')
|
||||
}
|
||||
conversation.value = Object.assign(getDefaultConversationData(), {
|
||||
topic: $i18n.t('newConversation')
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
onMounted(async () => {
|
||||
if (route.params.id) {
|
||||
conversation.value.loadingMessages = true
|
||||
await loadConversation()
|
||||
await loadMessage()
|
||||
conversation.value.loadingMessages = false
|
||||
} else {
|
||||
conversation.value = getDefaultConversationData()
|
||||
}
|
||||
currentConversation.value = Object.assign({}, conversation.value)
|
||||
})
|
||||
|
||||
|
||||
const navTitle = computed(() => {
|
||||
if (conversation.value && conversation.value.topic !== null) {
|
||||
return conversation.value.topic === '' ? $i18n.t('defaultConversationTitle') : conversation.value.topic
|
||||
}
|
||||
return runtimeConfig.public.appName
|
||||
})
|
||||
|
||||
onActivated(async () => {
|
||||
if (route.path === '/' && route.query.new !== undefined) {
|
||||
createNewConversation()
|
||||
}
|
||||
})
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-app-bar>
|
||||
<v-app-bar-nav-icon @click="drawer = !drawer"></v-app-bar-nav-icon>
|
||||
|
||||
<v-toolbar-title>{{ navTitle }}</v-toolbar-title>
|
||||
|
||||
<v-spacer></v-spacer>
|
||||
|
||||
<v-btn
|
||||
:title="$t('newConversation')"
|
||||
icon="add"
|
||||
@click="createNewConversation"
|
||||
class="d-md-none"
|
||||
></v-btn>
|
||||
<v-btn
|
||||
variant="outlined"
|
||||
class="text-none d-none d-md-block"
|
||||
@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>
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
})
|
||||
33
server/middleware/apiProxy.ts
Normal file
33
server/middleware/apiProxy.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
|
||||
}
|
||||
})
|
||||
@@ -3,7 +3,7 @@ export const getDefaultConversationData = () => {
|
||||
const { $i18n } = useNuxtApp()
|
||||
return {
|
||||
id: null,
|
||||
topic: $i18n.t('defaultConversationTitle'),
|
||||
topic: null,
|
||||
messages: [],
|
||||
loadingMessages: false,
|
||||
}
|
||||
@@ -17,11 +17,6 @@ export const getConversations = async () => {
|
||||
return []
|
||||
}
|
||||
|
||||
export const createNewConversation = () => {
|
||||
navigateTo('/')
|
||||
}
|
||||
|
||||
|
||||
export const addConversation = (conversation) => {
|
||||
const conversations = useConversations()
|
||||
conversations.value = [conversation, ...conversations.value]
|
||||
@@ -58,12 +53,27 @@ const transformData = (list) => {
|
||||
return result;
|
||||
}
|
||||
|
||||
export const loadSettings = async () => {
|
||||
const settings = useSettings()
|
||||
export const fetchSystemSettings = async () => {
|
||||
const { data, error } = await useAuthFetch('/api/chat/settings/', {
|
||||
method: 'GET'
|
||||
method: 'GET',
|
||||
})
|
||||
if (!error.value) {
|
||||
const settings = useSettings()
|
||||
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');
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import {MODELS} from "~/utils/enums";
|
||||
|
||||
const get = (key) => {
|
||||
if (process.server) return
|
||||
let val = localStorage.getItem(key)
|
||||
if (val) {
|
||||
val = JSON.parse(val)
|
||||
@@ -9,6 +9,7 @@ const get = (key) => {
|
||||
}
|
||||
|
||||
const set = (key, val) => {
|
||||
if (process.server) return
|
||||
localStorage.setItem(key, JSON.stringify(val))
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user