Compare commits
26 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
55279def0d | ||
|
|
fa14276d0a | ||
|
|
8718dc4ed1 | ||
|
|
fe814acfd9 | ||
|
|
1e4f14c9b7 | ||
|
|
137ca5ae1a | ||
|
|
8a9b705b99 | ||
|
|
82c1811034 | ||
|
|
0d6aef6872 | ||
|
|
3f3ab8c33b | ||
|
|
6522536291 | ||
|
|
2bca5a032c | ||
|
|
53460bd891 | ||
|
|
fb9e8b8c7d | ||
|
|
21dc2b9236 | ||
|
|
1a6bf1d239 | ||
|
|
3e3283029d | ||
|
|
16c9b0e230 | ||
|
|
836df995d0 | ||
|
|
5b9d52b177 | ||
|
|
deb627a9ab | ||
|
|
70efc09dae | ||
|
|
8ff914582a | ||
|
|
f20a3562f3 | ||
|
|
4a1adf6d00 | ||
|
|
ddce1c9721 |
@@ -1,4 +1,6 @@
|
||||
node_modules
|
||||
database.sqlite
|
||||
dist
|
||||
.idea
|
||||
.output
|
||||
.nuxt
|
||||
.env
|
||||
36
.github/workflows/docker-image-static.yml
vendored
Normal file
36
.github/workflows/docker-image-static.yml
vendored
Normal file
@@ -0,0 +1,36 @@
|
||||
name: Docker Image CI - static
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
|
||||
jobs:
|
||||
|
||||
build:
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
-
|
||||
name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
-
|
||||
name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v2
|
||||
-
|
||||
name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v2
|
||||
-
|
||||
name: Login to Docker Hub
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
-
|
||||
name: Build and push
|
||||
uses: docker/build-push-action@v4
|
||||
with:
|
||||
context: .
|
||||
file: static.Dockerfile
|
||||
push: true
|
||||
tags: wongsaang/chatgpt-ui-client:latest-static,wongsaang/chatgpt-ui-client:${{ github.ref_name }}-static
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -7,4 +7,3 @@ node_modules
|
||||
.env
|
||||
.idea
|
||||
dist
|
||||
database.sqlite
|
||||
|
||||
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"]
|
||||
13
README.md
13
README.md
@@ -4,13 +4,22 @@
|
||||
|
||||
[English](./README.md) | [中文](./docs/zh/README.md)
|
||||
|
||||
User guide: [https://wongsaang.github.io/chatgpt-ui-docs/](https://wongsaang.github.io/chatgpt-ui-docs/)
|
||||
|
||||
A ChatGPT web client that supports multiple users, multiple database connections for persistent data storage, supports i18n. Provides Docker images and quick deployment scripts.
|
||||
|
||||
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 +104,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:
|
||||
@@ -105,6 +117,7 @@ services:
|
||||
image: wongsaang/chatgpt-ui-wsgi-server:latest
|
||||
environment:
|
||||
- APP_DOMAIN=${APP_DOMAIN:-localhost:9000} # CSRF whitelist,Add the address of your chatgpt-ui-web-server here, default is localhost:9000
|
||||
- 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.
|
||||
#- OPENAI_API_PROXY=https://openai.proxy.com/v1 # Proxy for https://api.openai.com/v1
|
||||
- DJANGO_SUPERUSER_USERNAME=admin # default superuser name
|
||||
|
||||
13
app.vue
13
app.vue
@@ -1,8 +1,15 @@
|
||||
<script setup>
|
||||
onNuxtReady(() => {
|
||||
fetchSystemSettings()
|
||||
// api key
|
||||
const apiKey = useApiKey()
|
||||
apiKey.value = getStoredApiKey()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<NuxtLoadingIndicator />
|
||||
<NuxtLayout>
|
||||
<NuxtLoadingIndicator />
|
||||
<NuxtPage />
|
||||
</NuxtLayout>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,13 +1,13 @@
|
||||
<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()
|
||||
const fetchingResponse = ref(false)
|
||||
const messageQueue = []
|
||||
const frugalMode = ref(true)
|
||||
let isProcessingQueue = false
|
||||
|
||||
const props = defineProps({
|
||||
@@ -65,7 +65,8 @@ const fetchReply = async (message) => {
|
||||
const data = Object.assign({}, currentModel.value, {
|
||||
openaiApiKey: enableCustomApiKey.value ? openaiApiKey.value : null,
|
||||
message: message,
|
||||
conversationId: props.conversation.id
|
||||
conversationId: props.conversation.id,
|
||||
frugalMode: frugalMode.value
|
||||
}, webSearchParams)
|
||||
|
||||
try {
|
||||
@@ -77,6 +78,7 @@ const fetchReply = async (message) => {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(data),
|
||||
openWhenHidden: true,
|
||||
onopen(response) {
|
||||
if (response.ok && response.headers.get('content-type') === EventStreamContentType) {
|
||||
return;
|
||||
@@ -108,12 +110,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 +140,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,23 +169,26 @@ 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">
|
||||
<div
|
||||
v-if="conversation.loadingMessages"
|
||||
class="text-center"
|
||||
@@ -201,7 +200,7 @@ watchEffect(() => {
|
||||
</div>
|
||||
<div v-else>
|
||||
<div
|
||||
v-if="conversation.messages.length > 0"
|
||||
v-if="conversation.messages"
|
||||
ref="chatWindow"
|
||||
>
|
||||
<v-container>
|
||||
@@ -236,11 +235,14 @@ watchEffect(() => {
|
||||
|
||||
<div ref="grab" class="w-100" style="height: 200px;"></div>
|
||||
</div>
|
||||
<Welcome v-if="conversation.id === null && conversation.messages.length === 0" />
|
||||
</div>
|
||||
</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
|
||||
@@ -260,11 +262,42 @@ watchEffect(() => {
|
||||
<v-switch
|
||||
v-if="showWebSearchToggle"
|
||||
v-model="enableWebSearch"
|
||||
inline
|
||||
hide-details
|
||||
color="primary"
|
||||
:label="$t('webSearch')"
|
||||
></v-switch>
|
||||
<v-spacer></v-spacer>
|
||||
<v-switch
|
||||
v-model="frugalMode"
|
||||
inline
|
||||
hide-details
|
||||
color="primary"
|
||||
:label="$t('frugalMode')"
|
||||
></v-switch>
|
||||
<v-dialog
|
||||
transition="dialog-bottom-transition"
|
||||
width="auto"
|
||||
>
|
||||
<template v-slot:activator="{ props }">
|
||||
<v-icon
|
||||
color="grey"
|
||||
v-bind="props"
|
||||
icon="help_outline"
|
||||
></v-icon>
|
||||
</template>
|
||||
<template v-slot:default="{ isActive }">
|
||||
<v-card>
|
||||
<v-toolbar
|
||||
color="primary"
|
||||
:title="$t('frugalMode')"
|
||||
></v-toolbar>
|
||||
<v-card-text>
|
||||
{{ $t('frugalModeTip') }}
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</template>
|
||||
</v-dialog>
|
||||
</v-toolbar>
|
||||
</div>
|
||||
</v-footer>
|
||||
@@ -287,3 +320,9 @@ watchEffect(() => {
|
||||
</v-snackbar>
|
||||
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.footer {
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -8,10 +8,13 @@ const availableModels = [
|
||||
]
|
||||
const currentModelDefault = ref(MODELS[currentModel.value.name])
|
||||
|
||||
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 = () => {
|
||||
@@ -52,10 +54,6 @@ onMounted(() => {
|
||||
bindCopyCodeToButtons()
|
||||
})
|
||||
|
||||
onUpdated(() => {
|
||||
bindCopyCodeToButtons()
|
||||
})
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -64,18 +62,16 @@ onUpdated(() => {
|
||||
rounded="lg"
|
||||
elevation="2"
|
||||
>
|
||||
<v-card-text>
|
||||
<div
|
||||
ref="contentElm"
|
||||
v-html="contentHtml"
|
||||
class="chat-msg-content"
|
||||
class="chat-msg-content pa-3"
|
||||
></div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.chat-msg-content ol {
|
||||
.chat-msg-content ol, .chat-msg-content ul {
|
||||
padding-left: 2em;
|
||||
}
|
||||
.hljs-code-container {
|
||||
|
||||
@@ -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>
|
||||
<div
|
||||
class="flex-grow-1 d-flex align-center justify-space-between"
|
||||
@@ -25,67 +95,3 @@
|
||||
></v-btn>
|
||||
</div>
|
||||
</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>
|
||||
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>
|
||||
|
||||
18
composables/fetch.js
Normal file
18
composables/fetch.js
Normal file
@@ -0,0 +1,18 @@
|
||||
export const useMyFetch = (url, options = {}) => {
|
||||
let defaultOptions = {
|
||||
headers: {
|
||||
Accept: 'application/json'
|
||||
}
|
||||
}
|
||||
if (process.server) {
|
||||
defaultOptions.baseURL = process.env.SERVER_DOMAIN
|
||||
}
|
||||
return useFetch(url, Object.assign(defaultOptions, options))
|
||||
}
|
||||
export const useAuthFetch = async (url, options = {}) => {
|
||||
const res = await useMyFetch(url, options)
|
||||
if (res.error.value && res.error.value.status === 401) {
|
||||
await logout()
|
||||
}
|
||||
return res
|
||||
}
|
||||
@@ -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:
|
||||
@@ -17,6 +20,7 @@ services:
|
||||
image: wongsaang/chatgpt-ui-wsgi-server:latest
|
||||
environment:
|
||||
- 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.
|
||||
- DJANGO_SUPERUSER_USERNAME=admin # default superuser name
|
||||
- DJANGO_SUPERUSER_PASSWORD=password # default superuser password
|
||||
|
||||
@@ -4,13 +4,22 @@
|
||||
|
||||
[English](../../README.md) | [中文](./docs/zh/README.md)
|
||||
|
||||
用户指南: [https://wongsaang.github.io/chatgpt-ui-docs/zh/](https://wongsaang.github.io/chatgpt-ui-docs/zh/)
|
||||
|
||||
ChatGPT Web 客户端,支持多用户,支持 Mysql、PostgreSQL 等多种数据库连接进行数据持久化存储,支持多语言。提供 Docker 镜像和快速部署脚本。
|
||||
|
||||
本项目的服务端:[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 +100,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:
|
||||
@@ -101,6 +113,7 @@ services:
|
||||
image: wongsaang/chatgpt-ui-wsgi-server:latest
|
||||
environment:
|
||||
- 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 格式对照表
|
||||
#- OPENAI_API_PROXY=https://openai.proxy.com/v1 # https://api.openai.com/v1 的代理地址
|
||||
- DJANGO_SUPERUSER_USERNAME=admin # 默认超级用户
|
||||
|
||||
@@ -39,9 +39,21 @@
|
||||
"copied": "Copied",
|
||||
"delete": "Delete",
|
||||
"signOut": "Sign out",
|
||||
"resetPassword": "Reset password",
|
||||
"submit": "Submit",
|
||||
"agree": "Agree",
|
||||
"newPassword": "New password",
|
||||
"currentPassword": "Current password",
|
||||
"confirmPassword": "Confirm password",
|
||||
"yourPasswordHasBeenReset": "Your password has been reset",
|
||||
"nowYouNeedToSignInAgain": "Now you need to sign in again",
|
||||
"webSearch": "Web Search",
|
||||
"webSearchDefaultPrompt": "Web search results:\n\n[web_results]\nCurrent date: [current_date]\n\nInstructions: Using the provided web search results, write a comprehensive reply to the given query. Make sure to cite results using [[number](URL)] notation after the reference. If the provided search results refer to multiple subjects with the same name, write separate answers for each subject.\nQuery: [query]",
|
||||
"genTitlePrompt": "Generate a short title for the following content, no more than 10 words. \n\nContent: ",
|
||||
"maxTokenTips1": "The maximum context length of the current model is",
|
||||
"maxTokenTips2": "token, which includes the length of the prompt and the length of the generated text. The `Max Tokens` here refers to the length of the generated text. Therefore, you should leave some space for your prompt and not set it too large or to the maximum.",
|
||||
"frugalMode": "Frugal mode",
|
||||
"frugalModeTip": "Activate frugal mode, the client will not send historical messages to ChatGPT, which can save token consumption. If you want ChatGPT to understand the context of the conversation, please turn off frugal mode.",
|
||||
"welcomeScreen": {
|
||||
"introduction1": "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.",
|
||||
|
||||
@@ -39,9 +39,21 @@
|
||||
"copied": "Скопировано",
|
||||
"delete": "Удалить",
|
||||
"signOut": "Выход",
|
||||
"resetPassword": "Сбросить пароль",
|
||||
"submit": "Отправить",
|
||||
"agree": "Согласен",
|
||||
"newPassword": "Новый пароль",
|
||||
"currentPassword": "Текущий пароль",
|
||||
"confirmPassword": "Подтвердите пароль",
|
||||
"yourPasswordHasBeenReset": "Ваш пароль был сброшен",
|
||||
"nowYouNeedToSignInAgain": "Теперь вам нужно снова войти в систему",
|
||||
"webSearch": "Поиск в интернете",
|
||||
"webSearchDefaultPrompt": "Результаты веб-поиска:\n\n[web_results]\nТекущая дата: [current_date]\n\nИнструкции: Используя предоставленные результаты веб-поиска, напишите развернутый ответ на заданный запрос. Обязательно цитируйте результаты, используя обозначение [[number](URL)] после ссылки. Если предоставленные результаты поиска относятся к нескольким темам с одинаковым названием, напишите отдельные ответы для каждой темы.\nЗапрос: [query]",
|
||||
"genTitlePrompt": "Придумайте короткий заголовок для следующего содержания, не более 10 слов. \n\nСодержание: ",
|
||||
"maxTokenTips1": "The maximum context length of the current model is",
|
||||
"maxTokenTips2": "token, which includes the length of the prompt and the length of the generated text. The `Max Tokens` here refers to the length of the generated text. Therefore, you should leave some space for your prompt and not set it too large or to the maximum.",
|
||||
"frugalMode": "Frugal mode",
|
||||
"frugalModeTip": "Activate frugal mode, the client will not send historical messages to ChatGPT, which can save token consumption. If you want ChatGPT to understand the context of the conversation, please turn off frugal mode.",
|
||||
"welcomeScreen": {
|
||||
"introduction1": "является неофициальным клиентом для ChatGPT, но использует официальный API OpenAI.",
|
||||
"introduction2": "Вам понадобится ключ API OpenAI, прежде чем вы сможете использовать этот клиент.",
|
||||
|
||||
@@ -39,9 +39,21 @@
|
||||
"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": "当前模型的最大上下文长度为",
|
||||
"maxTokenTips2": "个 token,它包括了指令的长度和生成的文本长度。此处的最大 token 数量是指生成的文本长度。所以您应该为您的指令预留一些空间,不宜设置过大或拉满。",
|
||||
"frugalMode": "节俭模式",
|
||||
"frugalModeTip": "开启节俭模式,客户端不会把历史消息发送给ChatGPT,可以节省 token 的消耗。如果你想让 ChatGPT 了解对话的上下文,请关闭节俭模式。",
|
||||
"welcomeScreen": {
|
||||
"introduction1": "是一个非官方的ChatGPT客户端,但使用OpenAI的官方API",
|
||||
"introduction2": "在使用本客户端之前,您需要一个OpenAI API密钥。",
|
||||
|
||||
@@ -1,338 +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) {
|
||||
console.log('delete current conversation')
|
||||
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"
|
||||
>
|
||||
<div class="px-2 py-2">
|
||||
<v-list>
|
||||
<v-list-item>
|
||||
<v-btn
|
||||
block
|
||||
variant="outlined"
|
||||
prepend-icon="add"
|
||||
class="text-none"
|
||||
@click="createNewConversation"
|
||||
>
|
||||
{{ $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"
|
||||
:to="conversation.id ? `/${conversation.id}` : undefined"
|
||||
v-bind="props"
|
||||
>
|
||||
<v-list-item-title>{{ conversation.topic }}</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-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="createNewConversation()"
|
||||
></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,9 +2,9 @@ server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name localhost;
|
||||
root /app;
|
||||
|
||||
location / {
|
||||
root /app;
|
||||
index index.html;
|
||||
|
||||
try_files $uri $uri/ /index.html;
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
// https://nuxt.com/docs/api/configuration/nuxt-config
|
||||
const appName = process.env.NUXT_PUBLIC_APP_NAME ?? 'ChatGPT UI'
|
||||
|
||||
export default defineNuxtConfig({
|
||||
dev: false,
|
||||
ssr: false,
|
||||
debug: process.env.NODE_ENV !== 'production',
|
||||
ssr: process.env.SSR !== 'false',
|
||||
app: {
|
||||
head: {
|
||||
title: appName,
|
||||
@@ -28,7 +27,7 @@ export default defineNuxtConfig({
|
||||
modules: [
|
||||
'@kevinmarrec/nuxt-pwa',
|
||||
'@nuxtjs/color-mode',
|
||||
'@nuxtjs/i18n',
|
||||
'@nuxtjs/i18n'
|
||||
],
|
||||
pwa: {
|
||||
manifest: {
|
||||
@@ -68,15 +67,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,12 +12,13 @@
|
||||
"@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",
|
||||
"copy-to-clipboard": "^3.3.3",
|
||||
"highlight.js": "^11.7.0",
|
||||
"http-proxy-middleware": "3.0.0-beta.1",
|
||||
"is-mobile": "^3.1.1",
|
||||
"markdown-it": "^13.0.1",
|
||||
"nanoid": "^4.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"
|
||||
|
||||
175
pages/account/resetPassword.vue
Normal file
175
pages/account/resetPassword.vue
Normal 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>
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -83,7 +81,7 @@ const submit = async () => {
|
||||
}
|
||||
|
||||
const handleFieldUpdate = (field) => {
|
||||
// fieldErrors.value[field] = ''
|
||||
fieldErrors.value[field] = ''
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -122,7 +120,7 @@ const handleFieldUpdate = (field) => {
|
||||
:error-messages="fieldErrors.email"
|
||||
label="Email"
|
||||
variant="underlined"
|
||||
@@update:modelValue="handleFieldUpdate('email')"
|
||||
@update:modelValue="handleFieldUpdate('email')"
|
||||
clearable
|
||||
></v-text-field>
|
||||
|
||||
|
||||
@@ -4,32 +4,88 @@ definePageMeta({
|
||||
path: '/:id?',
|
||||
keepalive: true
|
||||
})
|
||||
|
||||
const { $i18n } = useNuxtApp()
|
||||
const runtimeConfig = useRuntimeConfig()
|
||||
const drawer = useDrawer()
|
||||
const route = useRoute()
|
||||
const currentConversation = useConversation()
|
||||
const conversation = ref(getDefaultConversationData())
|
||||
watchEffect(() => {
|
||||
if (!route.params.id) {
|
||||
conversation.value = getDefaultConversationData()
|
||||
|
||||
const loadConversation = async () => {
|
||||
const { data, error } = await useAuthFetch('/api/chat/conversations/' + route.params.id)
|
||||
if (!error.value) {
|
||||
conversation.value = Object.assign(conversation.value, data.value)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const loadMessage = async () => {
|
||||
conversation.value.loadingMessages = true
|
||||
const { data, error } = await useAuthFetch('/api/chat/messages/?conversationId=' + route.params.id)
|
||||
if (!error.value) {
|
||||
conversation.value.messages = data.value
|
||||
}
|
||||
conversation.value.loadingMessages = false
|
||||
}
|
||||
|
||||
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.id = parseInt(route.params.id)
|
||||
conversation.value.loadingMessages = true
|
||||
await loadConversation()
|
||||
await loadMessage()
|
||||
conversation.value.loadingMessages = false
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
})
|
||||
14
server/middleware/apiProxy.ts
Normal file
14
server/middleware/apiProxy.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { createProxyMiddleware } from 'http-proxy-middleware'
|
||||
export default defineEventHandler(async (event) => {
|
||||
await new Promise((resolve, reject) => {
|
||||
createProxyMiddleware({
|
||||
target: process.env.SERVER_DOMAIN,
|
||||
pathFilter: '/api',
|
||||
})(event.node.req, event.node.res, (err) => {
|
||||
if (err)
|
||||
reject(err)
|
||||
else
|
||||
resolve(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
22
static.Dockerfile
Normal file
22
static.Dockerfile
Normal file
@@ -0,0 +1,22 @@
|
||||
FROM node:18-alpine3.16 as builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json yarn.lock ./
|
||||
|
||||
RUN yarn install
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN rm -r server && SSR=false yarn generate
|
||||
|
||||
|
||||
FROM nginx:1.22-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=builder /app/.output/public .
|
||||
|
||||
COPY nginx.conf /etc/nginx/templates/default.conf.template
|
||||
|
||||
EXPOSE 80
|
||||
@@ -3,7 +3,7 @@ export const getDefaultConversationData = () => {
|
||||
const { $i18n } = useNuxtApp()
|
||||
return {
|
||||
id: null,
|
||||
topic: $i18n.t('defaultConversationTitle'),
|
||||
topic: null,
|
||||
messages: [],
|
||||
loadingMessages: false,
|
||||
}
|
||||
@@ -17,13 +17,6 @@ export const getConversations = async () => {
|
||||
return []
|
||||
}
|
||||
|
||||
export const createNewConversation = () => {
|
||||
const conversation = useConversation()
|
||||
conversation.value = getDefaultConversationData()
|
||||
navigateTo('/')
|
||||
}
|
||||
|
||||
|
||||
export const addConversation = (conversation) => {
|
||||
const conversations = useConversations()
|
||||
conversations.value = [conversation, ...conversations.value]
|
||||
@@ -60,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